chore: initial commit (starter)

This commit is contained in:
AI Engineer 2026-05-10 15:53:17 +08:00
commit 0a996ee6e4
7 changed files with 762 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.geminiignore

384
.log.meta.json Normal file
View File

@ -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
}
]
}

10
CHANGELOG.md Normal file
View File

@ -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`.

60
README.md Normal file
View File

@ -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.

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module apigo.cc/go/starter
go 1.26.1

277
starter.go Normal file
View File

@ -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
}

27
starter_test.go Normal file
View File

@ -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
}