diff --git a/.gitignore b/.gitignore index fee8173..c2bc806 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,28 @@ -wails3 -.task -vendor/ -.idea -node_modules/ +.idea/ +.vscode/ .DS_Store *.log -.env -.env.*.local +.core/ + +# Build artefacts +dist/ +bin/ +/core +/cli + +# Go +vendor/ +go.work.sum coverage/ coverage.out coverage.html -*.cache -/coverage.txt -bin/ -dist/ -tasks -/cli -/core -local.test -/i18n-validate -.angular/ +coverage.txt -patch_cov.* -go.work.sum -.kb -.core/ -.idea/ +# Environment / secrets +.env +.env.*.local + +# OS / tooling +.task +*.cache +node_modules/ diff --git a/docs/pkg/cli/daemon.md b/docs/pkg/cli/daemon.md index 140c7ee..05c16d9 100644 --- a/docs/pkg/cli/daemon.md +++ b/docs/pkg/cli/daemon.md @@ -5,7 +5,7 @@ description: Daemon process management, PID files, health checks, and execution # Daemon Mode -The framework provides both low-level daemon primitives and a high-level command group that adds `start`, `stop`, `status`, and `run` subcommands to your CLI. +The framework provides execution mode detection and signal handling for daemon processes. ## Execution Modes @@ -29,63 +29,9 @@ cli.IsStdinTTY() // stdin is a terminal? cli.IsStderrTTY() // stderr is a terminal? ``` -## Adding Daemon Commands +## Simple Daemon -`AddDaemonCommand` registers a command group with four subcommands: - -```go -func AddMyCommands(root *cli.Command) { - cli.AddDaemonCommand(root, cli.DaemonCommandConfig{ - Name: "daemon", // Command group name (default: "daemon") - Description: "Manage the worker", // Short description - PIDFile: "/var/run/myapp.pid", - HealthAddr: ":9090", - RunForeground: func(ctx context.Context, daemon *process.Daemon) error { - // Your long-running service logic here. - // ctx is cancelled on SIGINT/SIGTERM. - return runWorker(ctx) - }, - }) -} -``` - -This creates: - -- `myapp daemon start` -- Re-executes the binary as a background process with `CORE_DAEMON=1` -- `myapp daemon stop` -- Sends SIGTERM to the daemon, waits for shutdown (30s timeout, then SIGKILL) -- `myapp daemon status` -- Reports whether the daemon is running and queries health endpoints -- `myapp daemon run` -- Runs in the foreground (for development or process managers like systemd) - -### Custom Persistent Flags - -Add flags that apply to all daemon subcommands: - -```go -cli.AddDaemonCommand(root, cli.DaemonCommandConfig{ - // ... - Flags: func(cmd *cli.Command) { - cli.PersistentStringFlag(cmd, &configPath, "config", "c", "", "Config file") - }, - ExtraStartArgs: func() []string { - return []string{"--config", configPath} - }, -}) -``` - -`ExtraStartArgs` passes additional flags when re-executing the binary as a daemon. - -### Health Endpoints - -When `HealthAddr` is set, the daemon serves: - -- `GET /health` -- Liveness check (200 if server is up, 503 if health checks fail) -- `GET /ready` -- Readiness check (200 if `daemon.SetReady(true)` has been called) - -The `start` command waits up to 5 seconds for the health endpoint to become available before reporting success. - -## Simple Daemon (Manual) - -For cases where you do not need the full command group: +Use `cli.Context()` for cancellation-aware daemon loops: ```go func runDaemon(cmd *cli.Command, args []string) error { @@ -117,15 +63,3 @@ cli.Init(cli.Options{ ``` No manual signal handling is needed in commands. Use `cli.Context()` for cancellation-aware operations. - -## DaemonCommandConfig Reference - -| Field | Type | Description | -|-------|------|-------------| -| `Name` | `string` | Command group name (default: `"daemon"`) | -| `Description` | `string` | Short description for help text | -| `PIDFile` | `string` | PID file path (default flag value) | -| `HealthAddr` | `string` | Health check listen address (default flag value) | -| `RunForeground` | `func(ctx, daemon) error` | Service logic for foreground/daemon mode | -| `Flags` | `func(cmd)` | Registers custom persistent flags | -| `ExtraStartArgs` | `func() []string` | Additional args for background re-exec | diff --git a/docs/pkg/cli/getting-started.md b/docs/pkg/cli/getting-started.md index 8f669d9..9405f19 100644 --- a/docs/pkg/cli/getting-started.md +++ b/docs/pkg/cli/getting-started.md @@ -57,10 +57,10 @@ If a command returns an `*ExitError`, the process exits with that code. All othe This is the preferred way to register commands. It wraps your registration function in a Core service that participates in the lifecycle: ```go -func WithCommands(name string, register func(root *Command)) core.Option +func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) CommandSetup ``` -During startup, the Core framework calls your function with the root cobra command. Your function adds subcommands to it: +During `Main()`, the CLI calls your function with the Core instance. Internally it retrieves the root cobra command and passes it to your register function: ```go func AddScoreCommands(root *cli.Command) { @@ -98,18 +98,17 @@ func main() { } ``` -Where `Commands()` returns a slice of framework options: +Where `Commands()` returns a slice of `CommandSetup` functions: ```go package lemcmd import ( - "forge.lthn.ai/core/go/pkg/core" "forge.lthn.ai/core/cli/pkg/cli" ) -func Commands() []core.Option { - return []core.Option{ +func Commands() []cli.CommandSetup { + return []cli.CommandSetup{ cli.WithCommands("score", addScoreCommands), cli.WithCommands("gen", addGenCommands), cli.WithCommands("data", addDataCommands), @@ -141,7 +140,7 @@ If you need more control over the lifecycle: cli.Init(cli.Options{ AppName: "myapp", Version: "1.0.0", - Services: []core.Option{...}, + Services: []core.Service{...}, OnReload: func() error { return reloadConfig() }, }) defer cli.Shutdown() diff --git a/go.mod b/go.mod index dd0a981..9ab2521 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( ) require ( + forge.lthn.ai/core/go v0.3.2 // indirect forge.lthn.ai/core/go-inference v0.1.7 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect diff --git a/go.sum b/go.sum index 2d7cf52..b3913d6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ -forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= -forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ= +dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA= +dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ= +forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo= forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA= forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8= forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q= diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index f00ea3c..e1d6495 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -22,12 +22,7 @@ func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) if root, ok := c.App().Runtime.(*cobra.Command); ok { register(root) } - // Register locale FS if provided - if len(localeFS) > 0 && localeFS[0] != nil { - registeredCommandsMu.Lock() - registeredLocales = append(registeredLocales, localeFS[0]) - registeredCommandsMu.Unlock() - } + appendLocales(localeFS...) } } @@ -49,20 +44,35 @@ var ( // } func RegisterCommands(fn CommandRegistration, localeFS ...fs.FS) { registeredCommandsMu.Lock() - defer registeredCommandsMu.Unlock() registeredCommands = append(registeredCommands, fn) - for _, lfs := range localeFS { - if lfs != nil { - registeredLocales = append(registeredLocales, lfs) - } - } + attached := commandsAttached && instance != nil && instance.root != nil + root := instance + registeredCommandsMu.Unlock() + + appendLocales(localeFS...) // If commands already attached (CLI already running), attach immediately - if commandsAttached && instance != nil && instance.root != nil { - fn(instance.root) + if attached { + fn(root.root) } } +// appendLocales appends non-nil locale filesystems to the registry. +func appendLocales(localeFS ...fs.FS) { + var nonempty []fs.FS + for _, lfs := range localeFS { + if lfs != nil { + nonempty = append(nonempty, lfs) + } + } + if len(nonempty) == 0 { + return + } + registeredCommandsMu.Lock() + registeredLocales = append(registeredLocales, nonempty...) + registeredCommandsMu.Unlock() +} + // RegisteredLocales returns all locale filesystems registered by command packages. func RegisteredLocales() []fs.FS { registeredCommandsMu.Lock() diff --git a/pkg/cli/commands_test.go b/pkg/cli/commands_test.go index f522956..a2f6d1f 100644 --- a/pkg/cli/commands_test.go +++ b/pkg/cli/commands_test.go @@ -12,21 +12,16 @@ import ( // resetGlobals clears the CLI singleton and command registry for test isolation. func resetGlobals(t *testing.T) { t.Helper() - t.Cleanup(func() { - // Restore clean state after each test. - registeredCommandsMu.Lock() - registeredCommands = nil - commandsAttached = false - registeredCommandsMu.Unlock() - if instance != nil { - Shutdown() - } - instance = nil - once = sync.Once{} - }) + doReset() + t.Cleanup(doReset) +} +// doReset clears all package-level state. Only safe from a single goroutine +// with no concurrent RegisterCommands calls in flight (i.e. test setup/teardown). +func doReset() { registeredCommandsMu.Lock() registeredCommands = nil + registeredLocales = nil commandsAttached = false registeredCommandsMu.Unlock() if instance != nil { diff --git a/pkg/cli/daemon_cmd_test.go b/pkg/cli/daemon_cmd_test.go deleted file mode 100644 index fbab05e..0000000 --- a/pkg/cli/daemon_cmd_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package cli - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAddDaemonCommand_RegistersSubcommands(t *testing.T) { - root := &Command{Use: "test"} - - AddDaemonCommand(root, DaemonCommandConfig{ - Name: "daemon", - PIDFile: "/tmp/test-daemon.pid", - HealthAddr: "127.0.0.1:0", - }) - - // Should have the daemon command - daemonCmd, _, err := root.Find([]string{"daemon"}) - require.NoError(t, err) - require.NotNil(t, daemonCmd) - - // Should have subcommands - var subNames []string - for _, sub := range daemonCmd.Commands() { - subNames = append(subNames, sub.Name()) - } - assert.Contains(t, subNames, "start") - assert.Contains(t, subNames, "stop") - assert.Contains(t, subNames, "status") - assert.Contains(t, subNames, "run") -} - -func TestDaemonCommandConfig_DefaultName(t *testing.T) { - root := &Command{Use: "test"} - - AddDaemonCommand(root, DaemonCommandConfig{}) - - // Should default to "daemon" - daemonCmd, _, err := root.Find([]string{"daemon"}) - require.NoError(t, err) - require.NotNil(t, daemonCmd) -}