2026-03-20 12:08:19 +00:00
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
|
|
|
|
|
|
// Command is a DTO representing an executable operation.
|
|
|
|
|
// Commands don't know if they're root, child, or nested — the tree
|
|
|
|
|
// structure comes from composition via path-based registration.
|
|
|
|
|
//
|
|
|
|
|
// Register a command:
|
|
|
|
|
//
|
2026-03-20 13:30:22 +00:00
|
|
|
// c.Command("deploy", func(opts core.Options) core.Result {
|
2026-03-20 15:36:33 +00:00
|
|
|
// return core.Result{"deployed", true}
|
2026-03-20 12:08:19 +00:00
|
|
|
// })
|
|
|
|
|
//
|
|
|
|
|
// Register a nested command:
|
|
|
|
|
//
|
|
|
|
|
// c.Command("deploy/to/homelab", handler)
|
|
|
|
|
//
|
|
|
|
|
// Description is an i18n key — derived from path if omitted:
|
|
|
|
|
//
|
|
|
|
|
// "deploy" → "cmd.deploy.description"
|
|
|
|
|
// "deploy/to/homelab" → "cmd.deploy.to.homelab.description"
|
2026-03-18 01:43:03 +00:00
|
|
|
package core
|
|
|
|
|
|
|
|
|
|
import (
|
2026-03-20 12:08:19 +00:00
|
|
|
"sync"
|
2026-03-18 01:43:03 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// CommandAction is the function signature for command handlers.
|
|
|
|
|
//
|
2026-03-20 13:30:22 +00:00
|
|
|
// func(opts core.Options) core.Result
|
|
|
|
|
type CommandAction func(Options) Result
|
2026-03-18 01:43:03 +00:00
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// CommandLifecycle is implemented by commands that support managed lifecycle.
|
|
|
|
|
// Basic commands only need an action. Daemon commands implement Start/Stop/Signal
|
|
|
|
|
// via go-process.
|
|
|
|
|
type CommandLifecycle interface {
|
2026-03-20 13:30:22 +00:00
|
|
|
Start(Options) Result
|
|
|
|
|
Stop() Result
|
|
|
|
|
Restart() Result
|
|
|
|
|
Reload() Result
|
|
|
|
|
Signal(string) Result
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// Command is the DTO for an executable operation.
|
|
|
|
|
type Command struct {
|
2026-03-20 14:44:29 +00:00
|
|
|
Name string
|
fix: AX audit round 5 — full naming, Result returns throughout
Renames (via GoLand refactor):
- Option.K → Key, Option.V → Value
- Err.Op → Operation, Err.Msg → Message, Err.Err → Error
- CrashSystem.OS → OperatingSystem, Arch → Architecture
- TaskID → TaskIdentifier, TaskWithID → TaskWithIdentifier
- Ipc → IPC, BaseDir → BaseDirectory
- ServiceRuntime.Opts → Options
Return type changes:
- Options.Get, Config.Get → Result (was (any, bool))
- Embed.ReadDir → Result (was ([]fs.DirEntry, error))
- Translator.Translate, I18n.Translate → Result (was string)
Rule 6:
- data.go: propagate opts.Get failure, typed error for bad fs.FS
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-20 16:32:43 +00:00
|
|
|
Description string // i18n key — derived from path if empty
|
|
|
|
|
Path string // "deploy/to/homelab"
|
|
|
|
|
Action CommandAction // business logic
|
|
|
|
|
Lifecycle CommandLifecycle // optional — provided by go-process
|
|
|
|
|
Flags Options // declared flags
|
2026-03-20 14:44:29 +00:00
|
|
|
Hidden bool
|
fix: AX audit round 5 — full naming, Result returns throughout
Renames (via GoLand refactor):
- Option.K → Key, Option.V → Value
- Err.Op → Operation, Err.Msg → Message, Err.Err → Error
- CrashSystem.OS → OperatingSystem, Arch → Architecture
- TaskID → TaskIdentifier, TaskWithID → TaskWithIdentifier
- Ipc → IPC, BaseDir → BaseDirectory
- ServiceRuntime.Opts → Options
Return type changes:
- Options.Get, Config.Get → Result (was (any, bool))
- Embed.ReadDir → Result (was ([]fs.DirEntry, error))
- Translator.Translate, I18n.Translate → Result (was string)
Rule 6:
- data.go: propagate opts.Get failure, typed error for bad fs.FS
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-20 16:32:43 +00:00
|
|
|
commands map[string]*Command // child commands (internal)
|
2026-03-20 12:08:19 +00:00
|
|
|
mu sync.RWMutex
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// I18nKey returns the i18n key for this command's description.
|
|
|
|
|
//
|
|
|
|
|
// cmd with path "deploy/to/homelab" → "cmd.deploy.to.homelab.description"
|
|
|
|
|
func (cmd *Command) I18nKey() string {
|
2026-03-20 14:44:29 +00:00
|
|
|
if cmd.Description != "" {
|
|
|
|
|
return cmd.Description
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
2026-03-20 14:44:29 +00:00
|
|
|
path := cmd.Path
|
2026-03-20 12:08:19 +00:00
|
|
|
if path == "" {
|
2026-03-20 14:44:29 +00:00
|
|
|
path = cmd.Name
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
2026-03-20 12:34:38 +00:00
|
|
|
return Concat("cmd.", Replace(path, "/", "."), ".description")
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// Run executes the command's action with the given options.
|
|
|
|
|
//
|
fix: AX audit round 5 — full naming, Result returns throughout
Renames (via GoLand refactor):
- Option.K → Key, Option.V → Value
- Err.Op → Operation, Err.Msg → Message, Err.Err → Error
- CrashSystem.OS → OperatingSystem, Arch → Architecture
- TaskID → TaskIdentifier, TaskWithID → TaskWithIdentifier
- Ipc → IPC, BaseDir → BaseDirectory
- ServiceRuntime.Opts → Options
Return type changes:
- Options.Get, Config.Get → Result (was (any, bool))
- Embed.ReadDir → Result (was ([]fs.DirEntry, error))
- Translator.Translate, I18n.Translate → Result (was string)
Rule 6:
- data.go: propagate opts.Get failure, typed error for bad fs.FS
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-20 16:32:43 +00:00
|
|
|
// result := cmd.Run(core.Options{{Key: "target", Value: "homelab"}})
|
2026-03-20 13:30:22 +00:00
|
|
|
func (cmd *Command) Run(opts Options) Result {
|
2026-03-20 14:44:29 +00:00
|
|
|
if cmd.Action == nil {
|
2026-03-20 18:36:30 +00:00
|
|
|
return Result{E("core.Command.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false}
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
2026-03-20 14:44:29 +00:00
|
|
|
return cmd.Action(opts)
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// Start delegates to the lifecycle implementation if available.
|
2026-03-20 13:30:22 +00:00
|
|
|
func (cmd *Command) Start(opts Options) Result {
|
2026-03-20 14:44:29 +00:00
|
|
|
if cmd.Lifecycle != nil {
|
|
|
|
|
return cmd.Lifecycle.Start(opts)
|
2026-03-20 12:08:19 +00:00
|
|
|
}
|
|
|
|
|
return cmd.Run(opts)
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// Stop delegates to the lifecycle implementation.
|
2026-03-20 13:30:22 +00:00
|
|
|
func (cmd *Command) Stop() Result {
|
2026-03-20 14:44:29 +00:00
|
|
|
if cmd.Lifecycle != nil {
|
|
|
|
|
return cmd.Lifecycle.Stop()
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
2026-03-20 13:30:22 +00:00
|
|
|
return Result{}
|
2026-03-20 12:08:19 +00:00
|
|
|
}
|
2026-03-18 01:43:03 +00:00
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// Restart delegates to the lifecycle implementation.
|
2026-03-20 13:30:22 +00:00
|
|
|
func (cmd *Command) Restart() Result {
|
2026-03-20 14:44:29 +00:00
|
|
|
if cmd.Lifecycle != nil {
|
|
|
|
|
return cmd.Lifecycle.Restart()
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
2026-03-20 13:30:22 +00:00
|
|
|
return Result{}
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// Reload delegates to the lifecycle implementation.
|
2026-03-20 13:30:22 +00:00
|
|
|
func (cmd *Command) Reload() Result {
|
2026-03-20 14:44:29 +00:00
|
|
|
if cmd.Lifecycle != nil {
|
|
|
|
|
return cmd.Lifecycle.Reload()
|
2026-03-20 12:08:19 +00:00
|
|
|
}
|
2026-03-20 13:30:22 +00:00
|
|
|
return Result{}
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// Signal delegates to the lifecycle implementation.
|
2026-03-20 13:30:22 +00:00
|
|
|
func (cmd *Command) Signal(sig string) Result {
|
2026-03-20 14:44:29 +00:00
|
|
|
if cmd.Lifecycle != nil {
|
|
|
|
|
return cmd.Lifecycle.Signal(sig)
|
2026-03-20 12:08:19 +00:00
|
|
|
}
|
2026-03-20 13:30:22 +00:00
|
|
|
return Result{}
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// --- Command Registry (on Core) ---
|
2026-03-18 01:43:03 +00:00
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// commandRegistry holds the command tree.
|
|
|
|
|
type commandRegistry struct {
|
|
|
|
|
commands map[string]*Command
|
|
|
|
|
mu sync.RWMutex
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 14:44:29 +00:00
|
|
|
// Command gets or registers a command by path.
|
2026-03-20 12:08:19 +00:00
|
|
|
//
|
2026-03-20 14:44:29 +00:00
|
|
|
// c.Command("deploy", Command{Action: handler})
|
|
|
|
|
// r := c.Command("deploy")
|
|
|
|
|
func (c *Core) Command(path string, command ...Command) Result {
|
|
|
|
|
if len(command) == 0 {
|
2026-03-20 12:08:19 +00:00
|
|
|
c.commands.mu.RLock()
|
2026-03-20 14:44:29 +00:00
|
|
|
cmd, ok := c.commands.commands[path]
|
2026-03-20 12:08:19 +00:00
|
|
|
c.commands.mu.RUnlock()
|
2026-03-20 15:36:33 +00:00
|
|
|
return Result{cmd, ok}
|
2026-03-20 14:44:29 +00:00
|
|
|
}
|
2026-03-18 01:43:03 +00:00
|
|
|
|
2026-03-20 18:48:02 +00:00
|
|
|
if path == "" || HasPrefix(path, "/") || HasSuffix(path, "/") || Contains(path, "//") {
|
|
|
|
|
return Result{E("core.Command", Concat("invalid command path: \"", path, "\""), nil), false}
|
2026-03-20 14:44:29 +00:00
|
|
|
}
|
2026-03-18 01:43:03 +00:00
|
|
|
|
2026-03-20 14:44:29 +00:00
|
|
|
c.commands.mu.Lock()
|
|
|
|
|
defer c.commands.mu.Unlock()
|
2026-03-18 01:43:03 +00:00
|
|
|
|
2026-03-20 18:36:30 +00:00
|
|
|
if existing, exists := c.commands.commands[path]; exists && (existing.Action != nil || existing.Lifecycle != nil) {
|
2026-03-20 17:25:12 +00:00
|
|
|
return Result{E("core.Command", Concat("command \"", path, "\" already registered"), nil), false}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 14:44:29 +00:00
|
|
|
cmd := &command[0]
|
|
|
|
|
cmd.Name = pathName(path)
|
|
|
|
|
cmd.Path = path
|
|
|
|
|
if cmd.commands == nil {
|
|
|
|
|
cmd.commands = make(map[string]*Command)
|
|
|
|
|
}
|
2026-03-20 12:08:19 +00:00
|
|
|
|
2026-03-20 17:52:48 +00:00
|
|
|
// Preserve existing subtree when overwriting a placeholder parent
|
|
|
|
|
if existing, exists := c.commands.commands[path]; exists {
|
|
|
|
|
for k, v := range existing.commands {
|
|
|
|
|
if _, has := cmd.commands[k]; !has {
|
|
|
|
|
cmd.commands[k] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 14:44:29 +00:00
|
|
|
c.commands.commands[path] = cmd
|
2026-03-18 01:43:03 +00:00
|
|
|
|
2026-03-20 14:44:29 +00:00
|
|
|
// Build parent chain — "deploy/to/homelab" creates "deploy" and "deploy/to" if missing
|
|
|
|
|
parts := Split(path, "/")
|
|
|
|
|
for i := len(parts) - 1; i > 0; i-- {
|
|
|
|
|
parentPath := JoinPath(parts[:i]...)
|
|
|
|
|
if _, exists := c.commands.commands[parentPath]; !exists {
|
|
|
|
|
c.commands.commands[parentPath] = &Command{
|
|
|
|
|
Name: parts[i-1],
|
|
|
|
|
Path: parentPath,
|
|
|
|
|
commands: make(map[string]*Command),
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-20 14:44:29 +00:00
|
|
|
c.commands.commands[parentPath].commands[parts[i]] = cmd
|
|
|
|
|
cmd = c.commands.commands[parentPath]
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
2026-03-20 14:44:29 +00:00
|
|
|
|
|
|
|
|
return Result{OK: true}
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// Commands returns all registered command paths.
|
|
|
|
|
//
|
|
|
|
|
// paths := c.Commands()
|
|
|
|
|
func (c *Core) Commands() []string {
|
|
|
|
|
if c.commands == nil {
|
|
|
|
|
return nil
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
2026-03-20 12:08:19 +00:00
|
|
|
c.commands.mu.RLock()
|
|
|
|
|
defer c.commands.mu.RUnlock()
|
|
|
|
|
var paths []string
|
|
|
|
|
for k := range c.commands.commands {
|
|
|
|
|
paths = append(paths, k)
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
2026-03-20 12:08:19 +00:00
|
|
|
return paths
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 12:08:19 +00:00
|
|
|
// pathName extracts the last segment of a path.
|
|
|
|
|
// "deploy/to/homelab" → "homelab"
|
|
|
|
|
func pathName(path string) string {
|
feat: string.go — core string primitives, same pattern as array.go
HasPrefix, HasSuffix, TrimPrefix, TrimSuffix, Contains, Split, SplitN,
StringJoin, Replace, Lower, Upper, Trim, RuneCount.
utils.go and command.go now use string.go helpers — zero direct
strings import in either file.
234 tests, 79.8% coverage.
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-20 12:29:15 +00:00
|
|
|
parts := Split(path, "/")
|
2026-03-20 12:08:19 +00:00
|
|
|
return parts[len(parts)-1]
|
2026-03-18 01:43:03 +00:00
|
|
|
}
|