Plans 1-5 complete for core/go scope. 456 tests, 84.4% coverage, 100% AX-7 naming.
Critical bugs (Plan 1):
- P4-3+P7-3: ACTION broadcast calls all handlers with panic recovery
- P7-2+P7-4: RunE() with defer ServiceShutdown, Run() delegates
- P3-1: Startable/Stoppable return Result (breaking, clean)
- P9-1: Zero os/exec — App.Find() rewritten with os.Stat+PATH
- I3: Embed() removed, I15: New() comment fixed
- I9: CommandLifecycle removed → Command.Managed field
Registry[T] (Plan 2):
- Universal thread-safe named collection with 3 lock modes
- All 5 registries migrated: services, commands, drive, data, lock
- Insertion order preserved (fixes P4-1)
- c.RegistryOf("name") cross-cutting accessor
Action/Task system (Plan 3):
- Action type with Run()/Exists(), ActionHandler signature
- c.Action("name") dual-purpose accessor (register/invoke)
- TaskDef with Steps — sequential chain, async dispatch, previous-input piping
- Panic recovery on all Action execution
- broadcast() internal, ACTION() sugar
Process primitive (Plan 4):
- c.Process() returns Action sugar — Run/RunIn/RunWithEnv/Start/Kill/Exists
- No deps added — delegates to c.Action("process.*")
- Permission-by-registration: no handler = no capability
Missing primitives (Plan 5):
- core.ID() — atomic counter + crypto/rand suffix
- ValidateName() / SanitisePath() — reusable validation
- Fs.WriteAtomic() — write-to-temp-then-rename
- Fs.NewUnrestricted() / Fs.Root() — legitimate sandbox bypass
- AX-7: 456/456 tests renamed to TestFile_Function_{Good,Bad,Ugly}
Co-Authored-By: Virgil <virgil@lethean.io>
163 lines
4.7 KiB
Go
163 lines
4.7 KiB
Go
// 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:
|
|
//
|
|
// c.Command("deploy", func(opts core.Options) core.Result {
|
|
// return core.Result{"deployed", true}
|
|
// })
|
|
//
|
|
// 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"
|
|
package core
|
|
|
|
|
|
// CommandAction is the function signature for command handlers.
|
|
//
|
|
// func(opts core.Options) core.Result
|
|
type CommandAction func(Options) Result
|
|
|
|
// Command is the DTO for an executable operation.
|
|
// Commands are declarative — they carry enough information for multiple consumers:
|
|
// - core.Cli() runs the Action
|
|
// - core/cli adds rich help, completion, man pages
|
|
// - go-process wraps Managed commands with lifecycle (PID, health, signals)
|
|
//
|
|
// c.Command("serve", core.Command{
|
|
// Action: handler,
|
|
// Managed: "process.daemon", // go-process provides start/stop/restart
|
|
// })
|
|
type Command struct {
|
|
Name string
|
|
Description string // i18n key — derived from path if empty
|
|
Path string // "deploy/to/homelab"
|
|
Action CommandAction // business logic
|
|
Managed string // "" = one-shot, "process.daemon" = managed lifecycle
|
|
Flags Options // declared flags
|
|
Hidden bool
|
|
commands map[string]*Command // child commands (internal)
|
|
}
|
|
|
|
// 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 {
|
|
if cmd.Description != "" {
|
|
return cmd.Description
|
|
}
|
|
path := cmd.Path
|
|
if path == "" {
|
|
path = cmd.Name
|
|
}
|
|
return Concat("cmd.", Replace(path, "/", "."), ".description")
|
|
}
|
|
|
|
// Run executes the command's action with the given options.
|
|
//
|
|
// result := cmd.Run(core.NewOptions(core.Option{Key: "target", Value: "homelab"}))
|
|
func (cmd *Command) Run(opts Options) Result {
|
|
if cmd.Action == nil {
|
|
return Result{E("core.Command.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false}
|
|
}
|
|
return cmd.Action(opts)
|
|
}
|
|
|
|
// IsManaged returns true if this command has a managed lifecycle.
|
|
//
|
|
// if cmd.IsManaged() { /* go-process handles start/stop */ }
|
|
func (cmd *Command) IsManaged() bool {
|
|
return cmd.Managed != ""
|
|
}
|
|
|
|
// --- Command Registry (on Core) ---
|
|
|
|
// CommandRegistry holds the command tree. Embeds Registry[*Command]
|
|
// for thread-safe named storage with insertion order.
|
|
type CommandRegistry struct {
|
|
*Registry[*Command]
|
|
}
|
|
|
|
// Command gets or registers a command by path.
|
|
//
|
|
// c.Command("deploy", Command{Action: handler})
|
|
// r := c.Command("deploy")
|
|
func (c *Core) Command(path string, command ...Command) Result {
|
|
if len(command) == 0 {
|
|
return c.commands.Get(path)
|
|
}
|
|
|
|
if path == "" || HasPrefix(path, "/") || HasSuffix(path, "/") || Contains(path, "//") {
|
|
return Result{E("core.Command", Concat("invalid command path: \"", path, "\""), nil), false}
|
|
}
|
|
|
|
// Check for duplicate executable command
|
|
if r := c.commands.Get(path); r.OK {
|
|
existing := r.Value.(*Command)
|
|
if existing.Action != nil || existing.IsManaged() {
|
|
return Result{E("core.Command", Concat("command \"", path, "\" already registered"), nil), false}
|
|
}
|
|
}
|
|
|
|
cmd := &command[0]
|
|
cmd.Name = pathName(path)
|
|
cmd.Path = path
|
|
if cmd.commands == nil {
|
|
cmd.commands = make(map[string]*Command)
|
|
}
|
|
|
|
// Preserve existing subtree when overwriting a placeholder parent
|
|
if r := c.commands.Get(path); r.OK {
|
|
existing := r.Value.(*Command)
|
|
for k, v := range existing.commands {
|
|
if _, has := cmd.commands[k]; !has {
|
|
cmd.commands[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
c.commands.Set(path, cmd)
|
|
|
|
// 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 !c.commands.Has(parentPath) {
|
|
c.commands.Set(parentPath, &Command{
|
|
Name: parts[i-1],
|
|
Path: parentPath,
|
|
commands: make(map[string]*Command),
|
|
})
|
|
}
|
|
parent := c.commands.Get(parentPath).Value.(*Command)
|
|
parent.commands[parts[i]] = cmd
|
|
cmd = parent
|
|
}
|
|
|
|
return Result{OK: true}
|
|
}
|
|
|
|
// Commands returns all registered command paths in registration order.
|
|
//
|
|
// paths := c.Commands()
|
|
func (c *Core) Commands() []string {
|
|
if c.commands == nil {
|
|
return nil
|
|
}
|
|
return c.commands.Names()
|
|
}
|
|
|
|
// pathName extracts the last segment of a path.
|
|
// "deploy/to/homelab" → "homelab"
|
|
func pathName(path string) string {
|
|
parts := Split(path, "/")
|
|
return parts[len(parts)-1]
|
|
}
|