commit 0a996ee6e40ed6af8df1892d95a17e94f0afbc62 Author: AI Engineer Date: Sun May 10 15:53:17 2026 +0800 chore: initial commit (starter) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c8bafe --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.geminiignore diff --git a/.log.meta.json b/.log.meta.json new file mode 100644 index 0000000..0aa5701 --- /dev/null +++ b/.log.meta.json @@ -0,0 +1,384 @@ +{ + "debug": [ + { + "Index": 0, + "Name": "LogName", + "KeyName": "", + "AttachBefore": false, + "Color": "cyan", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 1, + "Name": "LogType", + "KeyName": "", + "AttachBefore": false, + "Color": "magenta", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 2, + "Name": "LogTime", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "time", + "Precision": 0, + "WithoutKey": false, + "Hide": false + }, + { + "Index": 3, + "Name": "TraceId", + "KeyName": "", + "AttachBefore": false, + "Color": "gray", + "Format": "", + "Precision": 0, + "WithoutKey": true, + "Hide": false + }, + { + "Index": 4, + "Name": "Image", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 5, + "Name": "Server", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 6, + "Name": "Debug", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": true, + "Hide": false + }, + { + "Index": 7, + "Name": "Extra", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": false + } + ], + "error": [ + { + "Index": 0, + "Name": "LogName", + "KeyName": "", + "AttachBefore": false, + "Color": "cyan", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 1, + "Name": "LogType", + "KeyName": "", + "AttachBefore": false, + "Color": "magenta", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 2, + "Name": "LogTime", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "time", + "Precision": 0, + "WithoutKey": false, + "Hide": false + }, + { + "Index": 3, + "Name": "TraceId", + "KeyName": "", + "AttachBefore": false, + "Color": "gray", + "Format": "", + "Precision": 0, + "WithoutKey": true, + "Hide": false + }, + { + "Index": 4, + "Name": "Image", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 5, + "Name": "Server", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 6, + "Name": "Error", + "KeyName": "", + "AttachBefore": false, + "Color": "red", + "Format": "", + "Precision": 0, + "WithoutKey": true, + "Hide": false + }, + { + "Index": 7, + "Name": "Extra", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": false + }, + { + "Index": 8, + "Name": "CallStacks", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": false + } + ], + "info": [ + { + "Index": 0, + "Name": "LogName", + "KeyName": "", + "AttachBefore": false, + "Color": "cyan", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 1, + "Name": "LogType", + "KeyName": "", + "AttachBefore": false, + "Color": "magenta", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 2, + "Name": "LogTime", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "time", + "Precision": 0, + "WithoutKey": false, + "Hide": false + }, + { + "Index": 3, + "Name": "TraceId", + "KeyName": "", + "AttachBefore": false, + "Color": "gray", + "Format": "", + "Precision": 0, + "WithoutKey": true, + "Hide": false + }, + { + "Index": 4, + "Name": "Image", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 5, + "Name": "Server", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 6, + "Name": "Info", + "KeyName": "", + "AttachBefore": false, + "Color": "cyan", + "Format": "", + "Precision": 0, + "WithoutKey": true, + "Hide": false + }, + { + "Index": 7, + "Name": "Extra", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": false + } + ], + "warning": [ + { + "Index": 0, + "Name": "LogName", + "KeyName": "", + "AttachBefore": false, + "Color": "cyan", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 1, + "Name": "LogType", + "KeyName": "", + "AttachBefore": false, + "Color": "magenta", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 2, + "Name": "LogTime", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "time", + "Precision": 0, + "WithoutKey": false, + "Hide": false + }, + { + "Index": 3, + "Name": "TraceId", + "KeyName": "", + "AttachBefore": false, + "Color": "gray", + "Format": "", + "Precision": 0, + "WithoutKey": true, + "Hide": false + }, + { + "Index": 4, + "Name": "Image", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 5, + "Name": "Server", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": true + }, + { + "Index": 6, + "Name": "Warning", + "KeyName": "", + "AttachBefore": false, + "Color": "yellow", + "Format": "", + "Precision": 0, + "WithoutKey": true, + "Hide": false + }, + { + "Index": 7, + "Name": "Extra", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": false + }, + { + "Index": 8, + "Name": "CallStacks", + "KeyName": "", + "AttachBefore": false, + "Color": "", + "Format": "", + "Precision": 0, + "WithoutKey": false, + "Hide": false + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..75973bc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog: @go/starter + +## v1.0.0 (2026-05-10) + +### 🚀 Features +- **Initial Release**: Migrated from `ssgo/starter` to `apigo.cc/go/starter`. +- **Background Mode**: Supports running services in the background with `-log` redirection. +- **PID Management**: Automatic PID file creation and cleanup. +- **Signal Handling**: Graceful shutdown on `SIGINT`/`SIGTERM`, reload support on `SIGHUP`. +- **Infrastructure Alignment**: Integrated with `go/file` and `go/log`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffa45ef --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# @go/starter + +Service starter for @go applications, supporting background mode, PID management, and signal handling. + +## Installation + +```bash +go get apigo.cc/go/starter +``` + +## Usage + +```go +package main + +import ( + "context" + "fmt" + "time" + + "apigo.cc/go/starter" +) + +func main() { + starter.SetInfo("myapp", "1.0.0") + + starter.OnStart(func(ctx context.Context) { + fmt.Println("Service starting...") + for { + select { + case <-ctx.Done(): + fmt.Println("Service stopping...") + return + case <-time.After(1 * time.Second): + fmt.Println("Tick") + } + } + }) + + starter.OnStop(func() { + fmt.Println("Service stopped.") + }) + + starter.Run() +} +``` + +## Commands + +- `start`: Start the service in background. +- `stop`: Stop the service. +- `restart`: Restart the service. +- `status`: Show service status. +- `-v`, `--version`: Show version. +- `-h`, `--help`: Show help. + +## Options + +- `-pid`: PID file path (default `.pid`). +- `-log`: Log file path for background mode. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2d8a24e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module apigo.cc/go/starter + +go 1.26.1 diff --git a/starter.go b/starter.go new file mode 100644 index 0000000..548c3b1 --- /dev/null +++ b/starter.go @@ -0,0 +1,277 @@ +package starter + +import ( + "context" + "flag" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "apigo.cc/go/file" + "apigo.cc/go/log" +) + +var ( + // Default configuration + appName = filepath.Base(os.Args[0]) + appVersion = "1.0.0" + pidPath = ".pid" + logPath = "" + stopTimeout = 5 * time.Second + + // Internal state + commands = make(map[string]*command) + onStart func(ctx context.Context) + onStop func() + onReload func() + + // Flags + flagSet = flag.NewFlagSet(appName, flag.ContinueOnError) +) + +type command struct { + name string + desc string + fn func() +} + +func init() { + flagSet.StringVar(&pidPath, "pid", ".pid", "PID file path") + flagSet.StringVar(&logPath, "log", "", "Log file path (for background mode)") + + AddCmd("start", "Start the service in background", startCmd) + AddCmd("stop", "Stop the service", stopCmd) + AddCmd("restart", "Restart the service", restartCmd) + AddCmd("status", "Show service status", statusCmd) +} + +// SetInfo sets the application name and version. +func SetInfo(name, version string) { + appName = name + appVersion = version +} + +// SetPidFile sets the default PID file path. +func SetPidFile(path string) { + pidPath = path +} + +// SetLogFile sets the default log file path. +func SetLogFile(path string) { + logPath = path +} + +// AddCmd adds a custom command. +func AddCmd(name, desc string, fn func()) { + commands[name] = &command{name: name, desc: desc, fn: fn} +} + +// OnStart sets the function to be called when the service starts. +// The context will be canceled when a stop signal is received. +func OnStart(fn func(ctx context.Context)) { + onStart = fn +} + +// OnStop sets the function to be called when the service is stopping. +func OnStop(fn func()) { + onStop = fn +} + +// OnReload sets the function to be called when SIGHUP is received. +func OnReload(fn func()) { + onReload = fn +} + +// Run parses arguments and executes the service. +func Run() { + flagSet.Usage = showHelp + if len(os.Args) > 1 { + arg := os.Args[1] + if cmd, ok := commands[arg]; ok { + // Subcommand detected, parse flags after the command + _ = flagSet.Parse(os.Args[2:]) + cmd.fn() + return + } + + // Check for help/version + switch arg { + case "-h", "--help", "help": + showHelp() + return + case "-v", "--version", "version": + fmt.Printf("%s version %s\n", appName, appVersion) + return + } + } + + // No starter command, treat all as app flags + _ = flagSet.Parse(os.Args[1:]) + runForeground() +} + +func showHelp() { + fmt.Printf("%s (%s)\n\nUsage:\n %s [command] [options]\n\nCommands:\n", + appName, appVersion, filepath.Base(os.Args[0])) + + var names []string + for cmdName := range commands { + names = append(names, cmdName) + } + sort.Strings(names) + + for _, cmdName := range names { + fmt.Printf(" %-10s %s\n", cmdName, commands[cmdName].desc) + } + + fmt.Println("\nOptions:") + flagSet.PrintDefaults() + fmt.Println("\nIf no command is provided, the service runs in the foreground.") +} + +func runForeground() { + pid := os.Getpid() + savePid(pid) + defer removePid() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Setup signal handling + sigChan := make(chan os.Signal, 2) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + + if onStart != nil { + go onStart(ctx) + } + + for sig := range sigChan { + if sig == syscall.SIGHUP { + if onReload != nil { + log.DefaultLogger.Info("Received SIGHUP. Reloading...") + onReload() + } + continue + } + + log.DefaultLogger.Info(fmt.Sprintf("Received signal: %v. Shutting down...", sig)) + break + } + + cancel() // Trigger context cancellation for onStart + + if onStop != nil { + onStop() + } + log.DefaultLogger.Info("Shutdown complete.") +} + +func startCmd() { + pid := loadPid() + if pid > 0 && isProcessRunning(pid) { + log.DefaultLogger.Info(fmt.Sprintf("%s is already running (PID %d)", appName, pid)) + return + } + + // Build arguments for background process + args := []string{} + for i := 1; i < len(os.Args); i++ { + if os.Args[i] != "start" { + args = append(args, os.Args[i]) + } + } + + cmd := exec.Command(os.Args[0], args...) + + if logPath != "" { + file.EnsureParentDir(logPath) + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err == nil { + cmd.Stdout = f + cmd.Stderr = f + } + } + + err := cmd.Start() + if err != nil { + log.DefaultLogger.Error(fmt.Sprintf("Failed to start %s: %v", appName, err)) + os.Exit(1) + } + + log.DefaultLogger.Info(fmt.Sprintf("%s started (PID %d)", appName, cmd.Process.Pid)) +} + +func stopCmd() { + pid := loadPid() + if pid <= 0 || !isProcessRunning(pid) { + log.DefaultLogger.Info(fmt.Sprintf("%s is not running", appName)) + return + } + + process, _ := os.FindProcess(pid) + log.DefaultLogger.Info(fmt.Sprintf("Stopping %s (PID %d)...", appName, pid)) + _ = process.Signal(syscall.SIGTERM) + + deadline := time.Now().Add(stopTimeout) + for time.Now().Before(deadline) { + if !isProcessRunning(pid) { + log.DefaultLogger.Info("Stopped OK") + removePid() + return + } + time.Sleep(200 * time.Millisecond) + } + + log.DefaultLogger.Info("Stop timeout, killing...") + _ = process.Kill() + removePid() +} + +func restartCmd() { + stopCmd() + time.Sleep(500 * time.Millisecond) + startCmd() +} + +func statusCmd() { + pid := loadPid() + if pid > 0 && isProcessRunning(pid) { + log.DefaultLogger.Info(fmt.Sprintf("%s is running (PID %d)", appName, pid)) + } else { + log.DefaultLogger.Info(fmt.Sprintf("%s is not running", appName)) + } +} + +func savePid(p int) { + file.EnsureParentDir(pidPath) + _ = os.WriteFile(pidPath, []byte(strconv.Itoa(p)), 0644) +} + +func loadPid() int { + data, err := os.ReadFile(pidPath) + if err != nil { + return 0 + } + p, _ := strconv.Atoi(strings.TrimSpace(string(data))) + return p +} + +func removePid() { + _ = os.Remove(pidPath) +} + +func isProcessRunning(p int) bool { + process, err := os.FindProcess(p) + if err != nil { + return false + } + err = process.Signal(syscall.Signal(0)) + return err == nil +} diff --git a/starter_test.go b/starter_test.go new file mode 100644 index 0000000..e7bf236 --- /dev/null +++ b/starter_test.go @@ -0,0 +1,27 @@ +package starter_test + +import ( + "context" + "testing" + + "apigo.cc/go/starter" +) + +func TestStarterBasics(t *testing.T) { + starter.SetInfo("TestApp", "1.0.0") + + // Since we are using a singleton, we just check if the methods can be called + starter.OnStart(func(ctx context.Context) { + // Mock start + }) + + starter.OnStop(func() { + // Mock stop + }) + + starter.OnReload(func() { + // Mock reload + }) + + // Run() cannot be easily tested here as it calls os.Exit and parses os.Args +}