Files
golib/shell/interface.go
nabbar 344498a7d8 Improvements, test & documentatons (2025-11 #3)
[root]
- UPDATE documentation: enhanced README and TESTING guidelines
- UPDATE security md file: fix minimal go version needed
- ADD script: add coverage_report.sh script (see TESTING for info)

[ioutils/aggregator]
- ADD package: add new package to simplify aggregation of multiple write
  to a unique writer function
- ADD documentation: add enhanced README and TESTING guidelines
- ADD tests: complete test suites with benchmarks, concurrency, and edge cases

[router]
- UPDATE documentation

[semaphore]
- FIX bug if given context is nil or have error trigger

[shell]
- UPDATE package & sub-package: fix bugs and optimize code
- ADD sub-package tty: allow to backup and restore tty setting
- ADD documentation: add enhanced README and TESTING guidelines
- ADD tests: complete test suites with benchmarks, concurrency, and edge cases

[socket]
- UPDATE package & sub-package: rename function Handler to HandlerFunc
- UPDATE package & sub-package: add new interface Handler to expose a
  socket compatible handler function

[Other]
- UPDATE go.mod: bump dependencies
2025-11-22 18:04:16 +01:00

541 lines
20 KiB
Go

/***********************************************************************************************************************
*
* MIT License
*
* Copyright (c) 2021 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*
**********************************************************************************************************************/
// Package shell provides an interactive command-line shell interface with command registration,
// execution, and terminal interaction capabilities.
//
// The package supports both direct command execution and interactive prompt mode using go-prompt.
// It provides a thread-safe command registry with prefix support, allowing organization of commands
// into namespaces (e.g., "sys:", "user:", etc.).
//
// # Key Features
//
// - Thread-safe command registration and execution using atomic operations
// - Command prefix support for namespacing and organization
// - Interactive prompt mode with autocomplete and suggestions
// - Terminal state management and restoration via tty subpackage
// - Command walking and inspection capabilities
// - Built-in quit/exit commands for interactive mode
// - Signal handling for graceful shutdown
//
// # Architecture
//
// The package consists of three main components:
//
// 1. Shell Interface: Public API for command management
// 2. Command Registry: Thread-safe storage using github.com/nabbar/golib/atomic.MapTyped
// 3. Terminal Management: TTY state preservation via github.com/nabbar/golib/shell/tty
//
// Commands are stored with their full name (prefix + command name) as the key, enabling
// efficient lookup and namespace isolation. The atomic map ensures all operations are
// thread-safe without explicit locking in user code.
//
// # Basic Usage
//
// Create a shell and register commands:
//
// sh := shell.New(nil) // nil TTYSaver for non-interactive mode
// sh.Add("", command.New("hello", "Say hello", func(out, err io.Writer, args []string) {
// fmt.Fprintln(out, "Hello, World!")
// }))
// sh.Run(os.Stdout, os.Stderr, []string{"hello"})
//
// # Interactive Mode
//
// Start an interactive prompt with autocomplete:
//
// // Create TTYSaver for terminal state management
// ttySaver, _ := tty.New(nil, true) // Enable signal handling
// sh := shell.New(ttySaver)
//
// // Register your commands...
// sh.Add("sys:", command.New("info", "System info", infoFunc))
// sh.Add("user:", command.New("list", "List users", listFunc))
//
// // Start interactive prompt (blocks until user exits)
// sh.RunPrompt(os.Stdout, os.Stderr)
//
// # Namespacing with Prefixes
//
// Organize commands into logical groups:
//
// sh := shell.New(nil)
//
// // System commands
// sh.Add("sys:", sysInfo, sysStatus, sysRestart)
//
// // User management commands
// sh.Add("user:", userAdd, userDel, userList)
//
// // Database commands
// sh.Add("db:", dbConnect, dbQuery, dbBackup)
//
// // Commands accessible as: sys:info, user:add, db:connect, etc.
//
// # Command Inspection
//
// Walk through all registered commands:
//
// sh := shell.New(nil)
// // ... register commands ...
//
// count := 0
// sh.Walk(func(name string, item command.Command) bool {
// fmt.Printf("%s: %s\n", name, item.Describe())
// count++
// return true // continue walking
// })
// fmt.Printf("Total: %d commands\n", count)
//
// # Error Handling
//
// The shell handles various error conditions gracefully:
// - Missing commands: Writes "Invalid command" to error writer
// - Nil commands: Writes "Command not runable..." to error writer
// - Empty arguments: Returns immediately without action
// - Terminal not available: Interactive mode fails with tty.ErrorNotTTY
//
// # Thread Safety
//
// All Shell methods are safe for concurrent use. The internal command registry uses
// github.com/nabbar/golib/atomic.MapTyped which provides lock-free thread-safe operations.
// Multiple goroutines can safely:
// - Add commands concurrently
// - Execute different commands simultaneously
// - Walk the registry while commands are being added
//
// # Dependencies
//
// This package depends on:
// - github.com/nabbar/golib/shell/command: Command interface and creation
// - github.com/nabbar/golib/shell/tty: Terminal state management
// - github.com/nabbar/golib/atomic: Thread-safe map implementation
// - github.com/c-bata/go-prompt: Interactive prompt library
//
// # Subpackages
//
// - command: Command interface and creation utilities
// - tty: Terminal state save/restore for interactive mode
//
// See also:
// - github.com/nabbar/golib/shell/command for creating commands
// - github.com/nabbar/golib/shell/tty for terminal state management
// - github.com/c-bata/go-prompt for prompt customization options
package shell
import (
"io"
libshl "github.com/c-bata/go-prompt"
libatm "github.com/nabbar/golib/atomic"
shlcmd "github.com/nabbar/golib/shell/command"
"github.com/nabbar/golib/shell/tty"
)
// Shell provides an interface for managing and executing shell commands.
// All methods are safe for concurrent use by multiple goroutines.
//
// The Shell maintains an internal command registry that can be modified via Add()
// and queried via Get(), Desc(), and Walk(). Commands can be executed directly
// via Run() or interactively via RunPrompt().
//
// Command Organization:
// - Commands can be registered with optional prefixes for namespacing
// - The same command name can exist in different namespaces
// - Commands are retrieved by their full name (prefix + name)
//
// Example:
//
// sh := shell.New()
// sh.Add("sys:", command.New("info", "System info", func(out, err io.Writer, args []string) {
// fmt.Fprintln(out, "System information...")
// }))
// sh.Run(os.Stdout, os.Stderr, []string{"sys:info"})
type Shell interface {
// Run executes a command with the provided arguments.
// The first element of args should be the command name (including any prefix).
// Subsequent elements are passed as arguments to the command's function.
//
// Parameters:
// - buf: Writer for standard output (can be nil)
// - err: Writer for error output (can be nil)
// - args: Command name and arguments. If empty or nil, the method returns immediately.
//
// Behavior:
// - If the command is not found, writes "Invalid command" to err writer
// - If the command is nil, writes "Command not runable..." to err writer
// - Otherwise, executes the command's function with the remaining arguments
//
// Thread-safety: Safe for concurrent use
//
// Example:
//
// sh.Run(os.Stdout, os.Stderr, []string{"hello", "Alice"})
Run(buf io.Writer, err io.Writer, args []string)
// Add registers one or more commands with an optional prefix.
// If a command with the same name already exists, it is replaced.
//
// Parameters:
// - prefix: Optional prefix to prepend to each command name (e.g., "sys:", "user:")
// - cmd: One or more commands to register. Nil commands are skipped.
//
// The full command name is formed by concatenating the prefix and the command's Name().
// Commands can be registered multiple times with different prefixes.
//
// Thread-safety: Safe for concurrent use
//
// Example:
//
// sh.Add("", command.New("help", "Show help", helpFunc))
// sh.Add("sys:", command.New("info", "System info", infoFunc))
Add(prefix string, cmd ...shlcmd.Command)
// Get retrieves a command by its full name (including prefix).
//
// The method performs an exact match lookup in the command registry.
// Command names are case-sensitive and must include any prefix used during registration.
//
// Parameters:
// - cmd: Full command name to search for (e.g., "help" or "sys:info")
//
// Returns:
// - command: The Command instance if found
// - found: true if the command exists, false otherwise
//
// Behavior:
// - Empty string will only match if a command was registered with empty name
// - Prefix is part of the lookup key: "info" != "sys:info"
// - Returns (nil, false) if command not found
//
// Thread-safety: Safe for concurrent use via atomic map operations
//
// Example:
//
// cmd, found := sh.Get("help")
// if found {
// fmt.Println("Description:", cmd.Describe())
// }
//
// sysCmd, found := sh.Get("sys:info")
// if !found {
// fmt.Println("Command not found")
// }
Get(cmd string) (shlcmd.Command, bool)
// Desc retrieves the description of a command by its full name.
//
// The method looks up the command and returns its description string.
// This is a convenience method that combines Get() and Command.Describe().
//
// Parameters:
// - cmd: Full command name to search for (including prefix if any)
//
// Returns:
// - string: The command's description, or empty string if command not found
//
// Behavior:
// - Returns empty string if command doesn't exist
// - Returns empty string if command is nil
// - Command names are case-sensitive
// - Prefix must be included: "info" != "sys:info"
//
// Thread-safety: Safe for concurrent use via atomic map operations
//
// Example:
//
// desc := sh.Desc("help")
// fmt.Println(desc) // "Show help"
//
// desc = sh.Desc("sys:info")
// if desc == "" {
// fmt.Println("Command not found")
// }
Desc(cmd string) string
// Walk iterates over all registered commands, allowing inspection and enumeration.
// The provided function is called once for each command in the registry.
//
// The iteration order is non-deterministic as it depends on the internal map structure.
// If you need ordered iteration, collect commands and sort them externally.
//
// Parameters:
// - fct: Function called for each command. If nil, Walk returns immediately.
//
// The function receives:
// - name: Full command name (including prefix)
// - item: The command instance (may be nil if cleanup detected)
//
// The function should return:
// - true: Continue walking to the next command
// - false: Stop walking immediately
//
// Use Cases:
// - Counting commands: Count total or by prefix
// - Generating help: Collect all command names and descriptions
// - Command validation: Check all commands meet certain criteria
// - Statistics: Gather metrics about registered commands
//
// Implementation Notes:
// The method uses atomic.MapTyped.Range() which provides a consistent snapshot
// of the registry during iteration. Nil commands are automatically removed
// during walking (cleanup phase).
//
// Thread-safety: Safe for concurrent use. The registry can be modified by other
// goroutines during walking without causing data races.
//
// Example - Count Commands:
//
// count := 0
// sh.Walk(func(name string, item command.Command) bool {
// count++
// return true
// })
// fmt.Printf("Total commands: %d\n", count)
//
// Example - List by Prefix:
//
// var sysCommands []string
// sh.Walk(func(name string, item command.Command) bool {
// if strings.HasPrefix(name, "sys:") {
// sysCommands = append(sysCommands, name)
// }
// return true
// })
//
// Example - Early Exit:
//
// found := false
// sh.Walk(func(name string, item command.Command) bool {
// if name == "special" {
// found = true
// return false // stop walking
// }
// return true
// })
Walk(fct func(name string, item shlcmd.Command) bool)
// RunPrompt starts an interactive shell prompt using the go-prompt library.
// This method blocks until the user exits the shell (via "quit" or "exit" commands).
//
// The prompt provides a rich interactive experience with autocomplete, suggestions,
// and terminal state management. It's designed for building interactive CLI tools
// and administrative shells.
//
// Prerequisites:
// The Shell must be created with a valid TTYSaver (via New()) for terminal state management.
// Signal handling should be enabled in the TTYSaver (sig=true in tty.New()) for graceful
// shutdown on Ctrl+C and other termination signals.
//
// Parameters:
// - out: Writer for command output (uses os.Stdout if nil)
// - err: Writer for error output (uses os.Stderr if nil)
// - opt: Optional go-prompt configuration options (see github.com/c-bata/go-prompt)
//
// Interactive Features:
// - Auto-completion: Tab completion of registered command names
// - Suggestions: Live dropdown with command descriptions
// - History: Arrow keys for command history navigation
// - Built-in commands: "quit" and "exit" to terminate the prompt
// - Prefix support: Autocompletes with namespace awareness
//
// Terminal Management:
// The method uses the TTYSaver provided during Shell creation to manage terminal state:
// 1. Terminal state is already saved by the TTYSaver
// 2. go-prompt enables raw mode for character-by-character input
// 3. Terminal state is restored on exit via defer
// 4. Signal handlers (if enabled in TTYSaver) ensure cleanup on interruption
//
// Signal Handling:
// If the Shell was created with a TTYSaver that has signal handling enabled
// (sig=true in tty.New()), the following signals are handled:
// - SIGINT (Ctrl+C): Restores terminal and exits gracefully
// - SIGTERM: Graceful shutdown from systemd/docker
// - SIGQUIT (Ctrl+\): Quit with terminal restoration
// - SIGHUP: Terminal hangup handling
//
// The signal handler goroutine is automatically started when RunPrompt begins.
//
// Blocking Behavior:
// This method blocks the calling goroutine until the user explicitly exits.
// To run the prompt in the background, start it in a separate goroutine:
//
// go sh.RunPrompt(os.Stdout, os.Stderr)
//
// Customization:
// Pass go-prompt options to customize appearance and behavior:
//
// sh.RunPrompt(out, err,
// prompt.OptionPrefix(">>> "),
// prompt.OptionTitle("My Shell"),
// prompt.OptionPrefixTextColor(prompt.Blue))
//
// Thread-safety: Safe to call concurrently, though typically called once.
// Multiple concurrent prompts will compete for terminal input.
//
// Example - Basic Interactive Shell:
//
// // Create TTYSaver with signal handling
// ttySaver, err := tty.New(nil, true)
// if err != nil {
// log.Fatal(err)
// }
//
// sh := shell.New(ttySaver)
// sh.Add("", command.New("hello", "Say hello", func(out, err io.Writer, args []string) {
// fmt.Fprintln(out, "Hello, World!")
// }))
// sh.Add("sys:", command.New("info", "System info", sysInfoFunc))
//
// // Blocks until user types "quit" or "exit"
// sh.RunPrompt(os.Stdout, os.Stderr)
//
// Example - Customized Prompt:
//
// ttySaver, _ := tty.New(nil, true)
// sh := shell.New(ttySaver)
// // ... register commands ...
//
// sh.RunPrompt(os.Stdout, os.Stderr,
// prompt.OptionPrefix("myapp> "),
// prompt.OptionTitle("MyApp Admin Shell"),
// prompt.OptionSuggestionBGColor(prompt.DarkGray))
//
// Example - Non-Interactive Mode (without TTYSaver):
//
// // For non-interactive command execution, use nil TTYSaver
// sh := shell.New(nil)
// sh.Add("", myCommand)
// sh.Run(os.Stdout, os.Stderr, []string{"mycommand", "arg1"})
// // Don't call RunPrompt() without a TTYSaver
//
// See also:
// - github.com/c-bata/go-prompt for available options and customization
// - github.com/nabbar/golib/shell/tty for terminal state management details
// - New() for Shell creation with TTYSaver
RunPrompt(out, err io.Writer, opt ...libshl.Option)
}
// New creates a new Shell instance with the specified TTYSaver for terminal management.
//
// The function initializes a Shell with a thread-safe atomic map for command storage
// and optional terminal state management via the provided TTYSaver. The Shell is
// immediately ready for use after creation.
//
// Parameters:
// - ts: TTYSaver for terminal state management. Can be nil for non-interactive use.
// - nil: Shell works in non-interactive mode (Run() only, no RunPrompt())
// - tty.New(nil, false): Basic TTYSaver without signal handling
// - tty.New(nil, true): TTYSaver with signal handling for interactive mode
//
// The TTYSaver is used by RunPrompt() to:
// - Save and restore terminal state (prevent corruption)
// - Handle termination signals (Ctrl+C, SIGTERM, etc.)
// - Manage terminal attributes for interactive input
//
// Return Value:
// Returns a Shell interface implementation with:
// - Empty command registry (no commands registered yet)
// - Thread-safe operations via github.com/nabbar/golib/atomic.MapTyped
// - Optional terminal management via provided TTYSaver
// - All methods (Add, Get, Run, Walk, RunPrompt) available immediately
//
// Implementation:
// Uses github.com/nabbar/golib/atomic.NewMapTyped for lock-free concurrent access.
// The map uses command full names (prefix + name) as keys and Command instances as values.
// The TTYSaver is stored in an atomic.Value for thread-safe access.
//
// Thread-safety:
// The returned Shell is safe for concurrent use by multiple goroutines without
// additional synchronization. All operations (Add, Get, Run, Walk, RunPrompt) can
// be called concurrently.
//
// Memory:
// The Shell has minimal memory overhead - atomic map structure plus TTYSaver reference.
// Commands are stored by reference, so memory usage scales with the number of
// registered commands.
//
// Example - Non-Interactive Shell (nil TTYSaver):
//
// // For simple command execution without terminal interaction
// sh := shell.New(nil)
// sh.Add("", command.New("hello", "Say hello", func(out, err io.Writer, args []string) {
// fmt.Fprintln(out, "Hello, World!")
// }))
// sh.Run(os.Stdout, os.Stderr, []string{"hello"})
//
// Example - Interactive Shell (with TTYSaver):
//
// // Create TTYSaver with signal handling for interactive mode
// ttySaver, err := tty.New(nil, true)
// if err != nil {
// log.Fatal(err)
// }
//
// sh := shell.New(ttySaver)
// sh.Add("sys:", command.New("info", "System info", infoFunc))
// sh.Add("user:", command.New("list", "List users", listFunc))
//
// // Start interactive prompt (blocks until user exits)
// sh.RunPrompt(os.Stdout, os.Stderr)
//
// Example - Administrative Shell with Namespaces:
//
// ttySaver, _ := tty.New(nil, true)
// sh := shell.New(ttySaver)
//
// // System commands
// sh.Add("sys:", sysInfo, sysRestart, sysStatus)
//
// // User commands
// sh.Add("user:", userList, userAdd, userDel)
//
// // Database commands
// sh.Add("db:", dbConnect, dbQuery, dbBackup)
//
// // Start interactive mode
// sh.RunPrompt(os.Stdout, os.Stderr,
// prompt.OptionPrefix("admin> "),
// prompt.OptionTitle("Admin Shell"))
//
// See also:
// - github.com/nabbar/golib/shell/tty.New() for creating TTYSaver instances
// - github.com/nabbar/golib/shell/command for creating commands
// - RunPrompt() for interactive mode (requires non-nil TTYSaver)
func New(ts tty.TTYSaver) Shell {
s := &shell{
c: libatm.NewMapTyped[string, shlcmd.Command](),
s: libatm.NewValue[tty.TTYSaver](),
}
if ts != nil {
s.s.Store(ts)
}
return s
}