This commit is contained in:
2025-05-23 15:40:41 +04:00
parent 0e545e56de
commit 532cda72d3
126 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
# HeroScript
A Go package for parsing and executing HeroScript, a small scripting language for defining actions.
## HeroScript Format
HeroScript is a simple scripting language with the following structure:
```heroscript
!!actor.action
name: 'value'
key: 'value'
numeric_value: 25
boolean_value: 1
description: '
A multiline description
can be added here.
It supports multiple paragraphs.
'
```
Key features:
- Every action starts with `!!` (for SAL actions)
- The first part is the actor (e.g., `mailclient`)
- The second part is the action name (e.g., `configure`)
- Parameters follow in an indented block
- Multiline strings are supported using single quotes
- Comments start with `//`
## Usage
### Basic Usage
```go
import (
"fmt"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
)
// Create a new playbook from HeroScript text
script := `
!!mailclient.configure
name: 'myname'
host: 'localhost'
port: 25
secure: 1
`
pb, err := playbook.NewFromText(script)
if err != nil {
// Handle error
}
// Access actions
for _, action := range pb.Actions {
fmt.Printf("Action: %s.%s\n", action.Actor, action.Name)
// Access parameters
name := action.Params.Get("name")
host := action.Params.Get("host")
port := action.Params.GetInt("port")
secure := action.Params.GetBool("secure")
// Do something with the action...
}
```
### Finding Actions
```go
// Find all actions for a specific actor
mailActions, err := pb.FindActions(0, "mailclient", "", playbook.ActionTypeUnknown)
if err != nil {
// Handle error
}
// Find a specific action
configAction, err := pb.GetAction(0, "mailclient", "configure")
if err != nil {
// Handle error
}
```
### Generating HeroScript
```go
// Generate HeroScript from the playbook
script := pb.HeroScript(true) // true to include done actions
fmt.Println(script)
// Generate HeroScript excluding done actions
script = pb.HeroScript(false)
fmt.Println(script)
```
## Action Types
HeroScript supports different action types:
- `!action` - DAL (Data Access Layer)
- `!!action` - SAL (Service Access Layer)
- `!!!action` - Macro
## Integration with HandlerFactory
The HeroScript package can be used with the HandlerFactory to process commands. Each handler is associated with an actor and implements methods for each action it supports.
### Handler Implementation
To create a handler that works with the HandlerFactory and HeroScript:
```go
// MyHandler handles actions for the "myactor" actor
type MyHandler struct {
handlerfactory.BaseHandler
}
// NewMyHandler creates a new MyHandler
func NewMyHandler() *MyHandler {
return &MyHandler{
BaseHandler: handlerfactory.BaseHandler{
ActorName: "myactor",
},
}
}
// Play processes all actions for this handler's actor
func (h *MyHandler) Play(script string, handler interface{}) (string, error) {
return h.BaseHandler.Play(script, handler)
}
// DoSomething handles the myactor.do_something action
func (h *MyHandler) DoSomething(script string) string {
log.Printf("MyActor.DoSomething called with: %s", script)
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
// Process the action...
return "Action completed successfully"
}
```
### Using with HandlerFactory
```go
// Create a new handler factory
factory := handlerfactory.NewHandlerFactory()
// Create and register a handler
myHandler := NewMyHandler()
err := factory.RegisterHandler(myHandler)
if err != nil {
log.Fatalf("Failed to register handler: %v", err)
}
// Process a HeroScript command
result, err := factory.ProcessHeroscript(`
!!myactor.do_something
param1: 'value1'
param2: 'value2'
`)
if err != nil {
log.Fatalf("Error processing heroscript: %v", err)
}
fmt.Println(result)
```
## Example
See the [example](./example/main.go) for a complete demonstration of how to use this package.
## Running Tests
```bash
go test -v ./pkg/heroscript/playbook
```

View File

@@ -0,0 +1,75 @@
package main
import (
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"git.ourworld.tf/herocode/heroagent/pkg/handlerfactory/herohandler"
)
func main() {
// Parse command line flags
socketPath := flag.String("socket", "/tmp/hero.sock", "Unix socket path")
tcpAddress := flag.String("tcp", "localhost:8023", "TCP address")
useUnixSocket := flag.Bool("unix", true, "Use Unix socket")
useTCP := flag.Bool("tcp-enable", false, "Use TCP")
flag.Parse()
// Initialize the hero handler
err := herohandler.Init()
if err != nil {
fmt.Printf("Failed to initialize hero handler: %v\n", err)
os.Exit(1)
}
// Get the default instance
handler := herohandler.DefaultInstance
// The fake handler is already registered in the Init() function
fmt.Println("Using pre-registered fake handler")
// Start the server
if *useUnixSocket || *useTCP {
fmt.Printf("Starting telnet server\n")
var socketPathStr, tcpAddressStr string
if *useUnixSocket {
socketPathStr = *socketPath
fmt.Printf("Unix socket: %s\n", socketPathStr)
}
if *useTCP {
tcpAddressStr = *tcpAddress
fmt.Printf("TCP address: %s\n", tcpAddressStr)
}
err = handler.StartTelnet(socketPathStr, tcpAddressStr)
if err != nil {
fmt.Printf("Failed to start telnet server: %v\n", err)
os.Exit(1)
}
}
// Print available commands
factory := handler.GetFactory()
actions := factory.GetSupportedActions()
fmt.Println("\nAvailable commands:")
for actor, commands := range actions {
fmt.Printf("Actor: %s\n", actor)
for _, command := range commands {
fmt.Printf(" !!%s.%s\n", actor, command)
}
}
fmt.Println("\nServer is running. Press Ctrl+C to stop.")
// Wait for interrupt signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
fmt.Println("\nShutting down...")
handler.StopTelnet()
fmt.Println("Server stopped")
}

View File

@@ -0,0 +1 @@
heroexecute

View File

@@ -0,0 +1,165 @@
package main
import (
"flag"
"fmt"
"io/ioutil"
"log"
"os"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
)
func main() {
// Define command line flags
parseCmd := flag.NewFlagSet("parse", flag.ExitOnError)
parseFile := parseCmd.String("file", "", "Path to heroscript file to parse")
parseText := parseCmd.String("text", "", "Heroscript text to parse")
parsePriority := parseCmd.Int("priority", 10, "Default priority for actions")
executeCmd := flag.NewFlagSet("execute", flag.ExitOnError)
executeFile := executeCmd.String("file", "", "Path to heroscript file to execute")
executeText := executeCmd.String("text", "", "Heroscript text to execute")
executePriority := executeCmd.Int("priority", 10, "Default priority for actions")
executeActor := executeCmd.String("actor", "", "Only execute actions for this actor")
executeAction := executeCmd.String("action", "", "Only execute actions with this name")
// Check if a subcommand is provided
if len(os.Args) < 2 {
fmt.Println("Expected 'parse' or 'execute' subcommand")
os.Exit(1)
}
// Parse the subcommand
switch os.Args[1] {
case "parse":
parseCmd.Parse(os.Args[2:])
handleParseCommand(*parseFile, *parseText, *parsePriority)
case "execute":
executeCmd.Parse(os.Args[2:])
handleExecuteCommand(*executeFile, *executeText, *executePriority, *executeActor, *executeAction)
default:
fmt.Println("Expected 'parse' or 'execute' subcommand")
os.Exit(1)
}
}
func handleParseCommand(file, text string, priority int) {
var pb *playbook.PlayBook
var err error
// Parse from file or text
if file != "" {
content, err := ioutil.ReadFile(file)
if err != nil {
log.Fatalf("Failed to read file: %v", err)
}
pb, err = playbook.NewFromText(string(content))
} else if text != "" {
pb, err = playbook.NewFromText(text)
} else {
log.Fatalf("Either -file or -text must be provided")
}
if err != nil {
log.Fatalf("Failed to parse heroscript: %v", err)
}
// Print the parsed playbook
fmt.Printf("Parsed %d actions:\n\n", len(pb.Actions))
for i, action := range pb.Actions {
fmt.Printf("Action %d: %s.%s (Priority: %d)\n", i+1, action.Actor, action.Name, action.Priority)
if action.Comments != "" {
fmt.Printf(" Comments: %s\n", action.Comments)
}
fmt.Printf(" Parameters:\n")
for key, value := range action.Params.GetAll() {
// Format multiline values
if strings.Contains(value, "\n") {
fmt.Printf(" %s: |\n", key)
lines := strings.Split(value, "\n")
for _, line := range lines {
fmt.Printf(" %s\n", line)
}
} else {
fmt.Printf(" %s: %s\n", key, value)
}
}
fmt.Println()
}
// Print the generated heroscript
fmt.Println("Generated HeroScript:")
fmt.Println("---------------------")
fmt.Println(pb.HeroScript(true))
fmt.Println("---------------------")
}
func handleExecuteCommand(file, text string, priority int, actor, action string) {
var pb *playbook.PlayBook
var err error
// Parse from file or text
if file != "" {
content, err := ioutil.ReadFile(file)
if err != nil {
log.Fatalf("Failed to read file: %v", err)
}
pb, err = playbook.NewFromText(string(content))
} else if text != "" {
pb, err = playbook.NewFromText(text)
} else {
log.Fatalf("Either -file or -text must be provided")
}
if err != nil {
log.Fatalf("Failed to parse heroscript: %v", err)
}
// Find actions to execute
var actionsToExecute []*playbook.Action
if actor != "" || action != "" {
// Find specific actions
actionsToExecute, err = pb.FindActions(0, actor, action, playbook.ActionTypeUnknown)
if err != nil {
log.Fatalf("Failed to find actions: %v", err)
}
} else {
// Execute all actions in priority order
actionsToExecute, err = pb.ActionsSorted(false)
if err != nil {
log.Fatalf("Failed to sort actions: %v", err)
}
}
// Execute the actions
fmt.Printf("Executing %d actions:\n\n", len(actionsToExecute))
for i, action := range actionsToExecute {
fmt.Printf("Executing action %d: %s.%s\n", i+1, action.Actor, action.Name)
// In a real implementation, you would have handlers for different actors and actions
// For this example, we'll just simulate execution
fmt.Printf(" Parameters:\n")
for key, value := range action.Params.GetAll() {
fmt.Printf(" %s: %s\n", key, value)
}
// Mark the action as done
action.Done = true
// Set some result data
action.Result.Set("status", "success")
action.Result.Set("execution_time", "0.5s")
fmt.Printf(" Result: success\n\n")
}
// Check if all actions are done
err = pb.EmptyCheck()
if err != nil {
fmt.Printf("Warning: %v\n", err)
} else {
fmt.Println("All actions executed successfully!")
}
}

View File

@@ -0,0 +1,2 @@
herohandler
example

View File

@@ -0,0 +1,106 @@
# HeroHandler Example
This package demonstrates how to implement and use a handler for HeroScript in the HeroLauncher project.
## Overview
The HeroHandler example provides a simple key-value store implementation that showcases how to:
1. Create a custom handler that extends the base handler functionality
2. Implement action methods that can be called via HeroScript
3. Parse parameters from HeroScript actions
4. Process and execute HeroScript commands
## Project Structure
```
./herohandler/
├── README.md # This documentation file
├── main.go # Main executable that uses the example handler
└── internal/ # Internal package for the example handler implementation
└── example_handler.go # Example handler implementation
```
## Handler Actions
The example handler supports the following actions:
- `example.set`: Store a key-value pair
- Parameters: `key`, `value`
- `example.get`: Retrieve a value by key
- Parameters: `key`
- `example.list`: List all stored key-value pairs
- No parameters
- `example.delete`: Remove a key-value pair
- Parameters: `key`
## Usage
You can run the example handler using the provided `main.go`:
```bash
# Build the binary
cd pkg/heroscript/cmd/herohandler
go build -o herohandler
# Set a key-value pair
./herohandler "example.set key:mykey value:myvalue"
# Get a value by key
./herohandler "example.get key:mykey"
# List all stored key-value pairs
./herohandler "example.list"
# Delete a key-value pair
./herohandler "example.delete key:mykey"
```
### Important Note on State Persistence
The example handler maintains its key-value store in memory only for the duration of a single command execution. Each time you run the `herohandler` command, a new instance of the handler is created with an empty data store. This is by design to keep the example simple.
In a real-world application, you would typically implement persistence using a database, file storage, or other mechanisms to maintain state between command executions.
### Multi-Command Example
To execute multiple commands in a single script, you can create a HeroScript file and pass it to the handler. For example:
```bash
# Create a script file
cat > example.hero << EOF
!!example.set key:user value:john
!!example.set key:role value:admin
!!example.list
EOF
# Run the script
cat example.hero | ./herohandler
```
This would process all commands in a single execution, allowing the in-memory state to be shared between commands.
## Implementation Details
The example handler demonstrates several important concepts:
1. **Handler Structure**: The `ExampleHandler` extends the `handlers.BaseHandler` which provides common functionality for all handlers.
2. **Action Methods**: Each action is implemented as a method on the handler struct (e.g., `Set`, `Get`, `List`, `Delete`).
3. **Parameter Parsing**: The `ParseParams` method from `BaseHandler` is used to extract parameters from HeroScript.
4. **Action Execution**: The `Play` method from `BaseHandler` uses reflection to find and call the appropriate method based on the action name.
5. **In-Memory Storage**: The example handler maintains a simple in-memory key-value store using a map.
## Extending the Example
To create your own handler:
1. Create a new struct that embeds the `handlers.BaseHandler`
2. Implement methods for each action your handler will support
3. Create a constructor function that initializes your handler with the appropriate actor name
4. Use the `Play` method to process HeroScript commands
For more complex handlers, you might need to add additional fields to store state or configuration.

View File

@@ -0,0 +1,7 @@
!!example.set key:username value:johndoe
!!example.set key:email value:john@example.com
!!example.set key:role value:admin
!!example.list
!!example.get key:username
!!example.delete key:email
!!example.list

View File

@@ -0,0 +1,102 @@
package internal
import (
"fmt"
"git.ourworld.tf/herocode/heroagent/pkg/handlerfactory"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlers"
)
// ExampleHandler handles example actions
type ExampleHandler struct {
handlers.BaseHandler
data map[string]string
}
// NewExampleHandler creates a new example handler
func NewExampleHandler() *ExampleHandler {
return &ExampleHandler{
BaseHandler: handlers.BaseHandler{
BaseHandler: handlerfactory.BaseHandler{
ActorName: "example",
},
},
data: make(map[string]string),
}
}
// Set handles the example.set action
func (h *ExampleHandler) Set(script string) string {
params, err := h.BaseHandler.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
key := params.Get("key")
if key == "" {
return "Error: key is required"
}
value := params.Get("value")
if value == "" {
return "Error: value is required"
}
h.data[key] = value
return fmt.Sprintf("Set %s = %s", key, value)
}
// Get handles the example.get action
func (h *ExampleHandler) Get(script string) string {
params, err := h.BaseHandler.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
key := params.Get("key")
if key == "" {
return "Error: key is required"
}
value, exists := h.data[key]
if !exists {
return fmt.Sprintf("Key '%s' not found", key)
}
return fmt.Sprintf("%s = %s", key, value)
}
// List handles the example.list action
func (h *ExampleHandler) List(script string) string {
if len(h.data) == 0 {
return "No data stored"
}
var result string
for key, value := range h.data {
result += fmt.Sprintf("%s = %s\n", key, value)
}
return result
}
// Delete handles the example.delete action
func (h *ExampleHandler) Delete(script string) string {
params, err := h.BaseHandler.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
key := params.Get("key")
if key == "" {
return "Error: key is required"
}
_, exists := h.data[key]
if !exists {
return fmt.Sprintf("Key '%s' not found", key)
}
delete(h.data, key)
return fmt.Sprintf("Deleted key '%s'", key)
}

View File

@@ -0,0 +1,101 @@
package main
import (
"bufio"
"fmt"
"io"
"os"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/cmd/herohandler/internal"
)
func main() {
// Create a new example handler
handler := internal.NewExampleHandler()
// Check if input is coming from stdin (piped input)
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) == 0 {
// Reading from stdin (pipe or redirect)
processStdin(handler)
return
}
// Check if there are command-line arguments
if len(os.Args) < 2 {
printUsage()
return
}
// Get the command from arguments
command := strings.Join(os.Args[1:], " ")
// Format as proper HeroScript with !! prefix if not already prefixed
script := command
if !strings.HasPrefix(script, "!!") {
script = "!!" + script
}
// Process the script
result, err := handler.Play(script, handler)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
// Print the result
fmt.Println(result)
}
func printUsage() {
fmt.Println("Usage: herohandler <action>")
fmt.Println(" cat script.hero | herohandler")
fmt.Println("\nExample commands:")
fmt.Println(" example.set key:mykey value:myvalue")
fmt.Println(" example.get key:mykey")
fmt.Println(" example.list")
fmt.Println(" example.delete key:mykey")
fmt.Println("\nNote: The command will be automatically formatted as HeroScript with !! prefix.")
fmt.Println(" You can also pipe a multi-line HeroScript file to process multiple commands.")
}
// processStdin reads and processes HeroScript from stdin
func processStdin(handler *internal.ExampleHandler) {
reader := bufio.NewReader(os.Stdin)
var scriptBuilder strings.Builder
// Read all lines from stdin
for {
line, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
fmt.Printf("Error reading from stdin: %v\n", err)
return
}
// Add the line to our script
scriptBuilder.WriteString(line)
// If we've reached EOF, break
if err == io.EOF {
break
}
}
// Process the complete script
script := scriptBuilder.String()
if script == "" {
fmt.Println("Error: Empty script")
return
}
// Process the script
result, err := handler.Play(script, handler)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
// Print the result
fmt.Println(result)
}

View File

@@ -0,0 +1,208 @@
package main
import (
"bufio"
"fmt"
"log"
"net"
"os"
"strings"
"syscall"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/handlerfactory/herohandler"
)
func main() {
// Start the herohandler in a goroutine
go startHeroHandler()
// Run a simple telnet test
time.Sleep(1 * time.Second) // Give the server time to start
fmt.Println("\n=== Testing HeroHandler Telnet Server ===")
fmt.Println("The herohandler is running with a process manager handler registered.")
fmt.Println("You can connect to the telnet server at:")
fmt.Println("- Unix socket: /tmp/hero.sock")
fmt.Println("- TCP address: localhost:8023")
fmt.Println("\nYou can use the following command to connect:")
fmt.Println(" telnet localhost 8023")
fmt.Println(" nc -U /tmp/hero.sock")
// Start interactive shell
fmt.Println("\n=== Interactive Shell ===")
fmt.Println("Type 'help' to see available commands")
fmt.Println("Type 'exit' to quit")
startInteractiveShell()
}
func startInteractiveShell() {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("> ")
input, err := reader.ReadString('\n')
if err != nil {
fmt.Println("Error reading input:", err)
continue
}
input = strings.TrimSpace(input)
switch input {
case "exit", "quit":
fmt.Println("Exiting...")
// Send termination signal to the herohandler goroutine
syscall.Kill(syscall.Getpid(), syscall.SIGINT)
return
case "help":
showHelp()
case "status":
fmt.Println("HeroHandler is running")
fmt.Println("Telnet server is active at:")
fmt.Println("- Unix socket: /tmp/hero.sock")
fmt.Println("- TCP address: localhost:8023")
case "actions":
showSupportedActions()
case "test":
runTestScript()
default:
if input != "" {
fmt.Println("Unknown command. Type 'help' to see available commands.")
}
}
}
}
func showHelp() {
fmt.Println("Available commands:")
fmt.Println(" help - Show this help message")
fmt.Println(" status - Show herohandler status")
fmt.Println(" actions - Show supported actions for registered handlers")
fmt.Println(" test - Run a test heroscript")
fmt.Println(" exit - Exit the program")
}
func showSupportedActions() {
// We need to implement this function to get supported actions
// Since we can't directly access the factory field, we'll use the telnet interface
script := "!!core.actions"
// Try TCP first, then Unix socket if TCP fails
result, err := Send(script, "localhost:8023", false)
if err != nil {
fmt.Printf("TCP connection failed, trying Unix socket: %v\n", err)
result, err = Send(script, "/tmp/hero.sock", true)
if err != nil {
fmt.Printf("Error getting supported actions: %v\n", err)
return
}
}
fmt.Println("Supported actions by actor:")
fmt.Println(result)
}
// Send connects to the telnet server and sends a command, returning the response
func Send(command string, address string, isUnixSocket bool) (string, error) {
var conn net.Conn
var err error
// Connect to the server based on the address type
if isUnixSocket {
conn, err = net.Dial("unix", address)
} else {
conn, err = net.Dial("tcp", address)
}
if err != nil {
return "", fmt.Errorf("error connecting to server: %v", err)
}
defer conn.Close()
// Create a reader for the connection
reader := bufio.NewReader(conn)
// Read the welcome message
_, err = reader.ReadString('\n')
if err != nil {
return "", fmt.Errorf("error reading welcome message: %v", err)
}
// Send the command
fmt.Fprintf(conn, "%s\n", command)
// Read the response with a timeout
result := ""
ch := make(chan string)
errCh := make(chan error)
go func() {
var response strings.Builder
for {
line, err := reader.ReadString('\n')
if err != nil {
errCh <- fmt.Errorf("error reading response: %v", err)
return
}
response.WriteString(line)
// If we've received a complete response, break
if strings.Contains(line, "\n") && strings.TrimSpace(line) == "" {
break
}
}
ch <- response.String()
}()
select {
case result = <-ch:
return result, nil
case err = <-errCh:
return "", err
case <-time.After(5 * time.Second):
return "", fmt.Errorf("timeout waiting for response")
}
}
func runTestScript() {
// Simple test script for the process manager
script := `
!!process.list format:json
`
fmt.Println("Running test script:")
fmt.Println(script)
// Send the script to the telnet server
// Try TCP first, then Unix socket if TCP fails
result, err := Send(script, "localhost:8023", false)
if err != nil {
fmt.Printf("TCP connection failed, trying Unix socket: %v\n", err)
result, err = Send(script, "/tmp/hero.sock", true)
if err != nil {
fmt.Printf("Unix socket connection failed: %v\n", err)
// We can't directly access the factory field, so we'll just report the error
fmt.Printf("Error: %v\n", err)
return
}
}
fmt.Println("Result:")
fmt.Println(result)
}
func startHeroHandler() {
if err := herohandler.Init(); err != nil {
log.Fatalf("Failed to start telnet server: %v", err)
}
if err := herohandler.StartTelnet(); err != nil {
log.Fatalf("Failed to start telnet server: %v", err)
}
}

View File

@@ -0,0 +1 @@
heroscriptexample

View File

@@ -0,0 +1,75 @@
package main
import (
"fmt"
"log"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
)
const exampleScript = `
//This is a mail client configuration
!!mailclient.configure
name: 'mymail'
host: 'smtp.example.com'
port: 25
secure: 1
reset: 1
description: '
This is a multiline description
for my mail client configuration.
It supports multiple paragraphs.
'
//System update action
!!system.update
force: 1
packages: 'git,curl,wget'
`
func main() {
// Parse heroscript
pb, err := playbook.NewFromText(exampleScript)
if err != nil {
log.Fatalf("Failed to parse heroscript: %v", err)
}
// Print the playbook
fmt.Println("Playbook contains:")
fmt.Printf("- %d actions\n", len(pb.Actions))
fmt.Println("- Hash: " + pb.HashKey())
fmt.Println()
// Print each action
for i, action := range pb.Actions {
fmt.Printf("Action %d: %s.%s\n", i+1, action.Actor, action.Name)
fmt.Printf(" Comments: %s\n", action.Comments)
fmt.Printf(" Parameters:\n")
for key, value := range action.Params.GetAll() {
fmt.Printf(" %s: %s\n", key, value)
}
fmt.Println()
}
// Generate heroscript
fmt.Println("Generated HeroScript:")
fmt.Println("---------------------")
fmt.Println(pb.HeroScript(true))
fmt.Println("---------------------")
// Demonstrate finding actions
mailActions, err := pb.FindActions(0, "mailclient", "", playbook.ActionTypeUnknown)
if err != nil {
log.Fatalf("Error finding actions: %v", err)
}
fmt.Printf("\nFound %d mail client actions\n", len(mailActions))
// Mark an action as done
if len(pb.Actions) > 0 {
pb.Actions[0].Done = true
fmt.Println("\nAfter marking first action as done:")
fmt.Println(pb.HeroScript(false)) // Don't show done actions
}
}

View File

@@ -0,0 +1,19 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
vmhandler
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work

View File

@@ -0,0 +1,134 @@
# VM Handler Example
This example demonstrates how to use the HandlerFactory with a VM handler to process heroscript commands.
## Overview
The VM handler example shows how to:
1. Create a handler that processes VM-related actions
2. Register the handler with the HandlerFactory
3. Start a telnet server that uses the HandlerFactory to process commands
4. Connect to the telnet server and send heroscript commands
## Running the Example
To run the example:
```bash
cd ~/code/github/freeflowuniverse/herocode/heroagent/pkg/handlerfactory/cmd/vmhandler
go run . tutorial
#to run just the server do
go run .
#you can then go to other terminal and play with telnet / nc
```
This will start a telnet server on:
- Unix socket: `/tmp/vmhandler.sock`
- TCP: `localhost:8024`
## Connecting to the Server
### Using Unix Socket
```bash
nc -U /tmp/vmhandler.sock
```
### Using TCP
```bash
telnet localhost 8024
```
## Authentication
When you connect, you'll need to authenticate with the secret:
```
!!auth secret:1234
```
## Available Commands
Once authenticated, you can use the following commands:
```
!!vm.define name:'test_vm' cpu:4 memory:'8GB' storage:'100GB'
!!vm.start name:'test_vm'
!!vm.stop name:'test_vm'
!!vm.disk_add name:'test_vm' size:'50GB' type:'SSD'
!!vm.list
!!vm.status name:'test_vm'
!!vm.delete name:'test_vm' force:true
```
## Example Session
Here's an example session:
```
$ telnet localhost 8024
Connected to localhost.
Escape character is '^]'.
** Welcome: you are not authenticated, provide secret.
!!auth secret:1234
** Welcome: you are authenticated.
!!vm.define name:'test_vm' cpu:4 memory:'8GB' storage:'100GB'
VM 'test_vm' defined successfully with 4 CPU, 8GB memory, and 100GB storage
!!vm.start name:'test_vm'
VM 'test_vm' started successfully
!!vm.disk_add name:'test_vm' size:'50GB' type:'SSD'
Added 50GB SSD disk to VM 'test_vm'
!!vm.status name:'test_vm'
VM 'test_vm' status:
- Status: running
- CPU: 4
- Memory: 8GB
- Storage: 100GB
- Attached disks:
1. 50GB SSD
!!vm.list
Defined VMs:
- test_vm (running): 4 CPU, 8GB memory, 100GB storage
Attached disks:
1. 50GB SSD
!!vm.stop name:'test_vm'
VM 'test_vm' stopped successfully
!!vm.delete name:'test_vm'
VM 'test_vm' deleted successfully
!!quit
Goodbye!
Connection closed by foreign host.
```
## Other Commands
- `!!help`, `h`, or `?` - Show help
- `!!interactive` or `!!i` - Toggle interactive mode (with colors)
- `!!quit`, `!!exit`, or `q` - Disconnect from server
## How It Works
1. The `main.go` file creates a HandlerFactory and registers the VM handler
2. It starts a telnet server that uses the HandlerFactory to process commands
3. When a client connects and sends a heroscript command, the server:
- Parses the command to determine the actor and action
- Calls the appropriate method on the VM handler
- Returns the result to the client
## Extending the Example
You can extend this example by:
1. Adding more methods to the VM handler
2. Creating new handlers for different actors
3. Registering multiple handlers with the HandlerFactory

View File

@@ -0,0 +1,276 @@
package main
import (
"bufio"
"fmt"
"os"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/handlerfactory"
)
// runTutorial runs an interactive tutorial demonstrating the VM handler
func runTutorial() {
fmt.Println("=== VM Handler Tutorial ===")
fmt.Println("This tutorial will demonstrate how to use the VM handler with heroscript commands.")
fmt.Println("Press Enter to continue through each step...")
waitForEnter()
// Create a new handler factory
fmt.Println("\nStep 1: Create a new HandlerFactory")
fmt.Println("factory := handlerfactory.NewHandlerFactory()")
factory := handlerfactory.NewHandlerFactory()
waitForEnter()
// Create a VM handler
fmt.Println("\nStep 2: Create a VM handler")
fmt.Println("vmHandler := NewVMHandler()")
vmHandler := NewVMHandler()
waitForEnter()
// Register the VM handler with the factory
fmt.Println("\nStep 3: Register the VM handler with the factory")
fmt.Println("factory.RegisterHandler(vmHandler)")
err := factory.RegisterHandler(vmHandler)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Println("Handler registered successfully!")
waitForEnter()
// Show available actions
fmt.Println("\nStep 4: List available actions for the VM handler")
actions := factory.GetSupportedActions()
fmt.Println("Supported actions for 'vm' actor:")
for _, action := range actions["vm"] {
fmt.Printf("- %s\n", action)
}
waitForEnter()
// Process heroscript commands
fmt.Println("\nStep 5: Process heroscript commands")
// Define a VM
defineScript := `!!vm.define name:'tutorial_vm' cpu:2 memory:'4GB' storage:'50GB'
description: 'A tutorial VM for demonstration purposes'`
fmt.Println("\nCommand:")
fmt.Println(defineScript)
fmt.Println("\nProcessing...")
time.Sleep(1 * time.Second)
result, err := factory.ProcessHeroscript(defineScript)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Result: %s\n", result)
}
waitForEnter()
// Start the VM
startScript := `!!vm.start name:'tutorial_vm'`
fmt.Println("\nCommand:")
fmt.Println(startScript)
fmt.Println("\nProcessing...")
time.Sleep(1 * time.Second)
result, err = factory.ProcessHeroscript(startScript)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Result: %s\n", result)
}
waitForEnter()
// Add a disk
diskAddScript := `!!vm.disk_add name:'tutorial_vm' size:'20GB' type:'SSD'`
fmt.Println("\nCommand:")
fmt.Println(diskAddScript)
fmt.Println("\nProcessing...")
time.Sleep(1 * time.Second)
result, err = factory.ProcessHeroscript(diskAddScript)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Result: %s\n", result)
}
waitForEnter()
// Check VM status
statusScript := `!!vm.status name:'tutorial_vm'`
fmt.Println("\nCommand:")
fmt.Println(statusScript)
fmt.Println("\nProcessing...")
time.Sleep(1 * time.Second)
result, err = factory.ProcessHeroscript(statusScript)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Result: %s\n", result)
}
waitForEnter()
// List all VMs
listScript := `!!vm.list`
fmt.Println("\nCommand:")
fmt.Println(listScript)
fmt.Println("\nProcessing...")
time.Sleep(1 * time.Second)
result, err = factory.ProcessHeroscript(listScript)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Result: %s\n", result)
}
waitForEnter()
// Stop the VM
stopScript := `!!vm.stop name:'tutorial_vm'`
fmt.Println("\nCommand:")
fmt.Println(stopScript)
fmt.Println("\nProcessing...")
time.Sleep(1 * time.Second)
result, err = factory.ProcessHeroscript(stopScript)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Result: %s\n", result)
}
waitForEnter()
// Delete the VM
deleteScript := `!!vm.delete name:'tutorial_vm'`
fmt.Println("\nCommand:")
fmt.Println(deleteScript)
fmt.Println("\nProcessing...")
time.Sleep(1 * time.Second)
result, err = factory.ProcessHeroscript(deleteScript)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Result: %s\n", result)
}
waitForEnter()
// Try an invalid command
invalidScript := `!!vm.invalid name:'tutorial_vm'`
fmt.Println("\nInvalid Command:")
fmt.Println(invalidScript)
fmt.Println("\nProcessing...")
time.Sleep(1 * time.Second)
result, err = factory.ProcessHeroscript(invalidScript)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Result: %s\n", result)
}
waitForEnter()
// Conclusion
fmt.Println("\nTutorial Complete!")
fmt.Println("You've seen how to:")
fmt.Println("1. Create a HandlerFactory")
fmt.Println("2. Register a VM handler")
fmt.Println("3. Process various heroscript commands")
fmt.Println("\nTo run the full telnet server example, execute:")
fmt.Println("go run main.go vm_handler.go")
fmt.Println("\nPress Enter to exit the tutorial...")
waitForEnter()
}
// waitForEnter waits for the user to press Enter
func waitForEnter() {
reader := bufio.NewReader(os.Stdin)
reader.ReadString('\n')
}
// VMTutorial contains the tutorial text for the VM handler
const VMTutorial = `
VM Handler Tutorial
==================
The VM handler provides a set of commands to manage virtual machines through heroscript commands.
Available VM commands:
!!vm.define name:test_vm cpu:4 memory:8GB storage:100GB
!!vm.start name:test_vm
!!vm.stop name:test_vm
!!vm.disk_add name:test_vm size:50GB type:SSD
!!vm.list
!!vm.status name:test_vm
!!vm.delete name:test_vm force:true
Authentication secret: 1234
Command Details:
--------------
1. define - Create a new VM with specified resources
Parameters:
- name: (required) Name of the VM
- cpu: (optional) Number of CPUs, default: 1
- memory: (optional) Memory size, default: 1GB
- storage: (optional) Storage size, default: 10GB
- description: (optional) Description of the VM
2. start - Start a VM
Parameters:
- name: (required) Name of the VM to start
3. stop - Stop a running VM
Parameters:
- name: (required) Name of the VM to stop
4. disk_add - Add a disk to a VM
Parameters:
- name: (required) Name of the VM
- size: (optional) Size of the disk, default: 10GB
- type: (optional) Type of disk (SSD, HDD), default: HDD
5. list - List all VMs
6. status - Show status of a VM
Parameters:
- name: (required) Name of the VM
7. delete - Delete a VM
Parameters:
- name: (required) Name of the VM
- force: (optional) Force deletion even if VM is running, default: false
8. help - Show this help message
Examples:
--------
1. Create a new VM:
!!vm.define name:webserver cpu:2 memory:4GB storage:50GB description:'Web server VM'
2. Start the VM:
!!vm.start name:webserver
3. Add an SSD disk:
!!vm.disk_add name:webserver size:100GB type:SSD
4. Check VM status:
!!vm.status name:webserver
5. List all VMs:
!!vm.list
6. Stop the VM:
!!vm.stop name:webserver
7. Delete the VM:
!!vm.delete name:webserver force:true
`
// addTutorialCommand adds the tutorial command to the main function
func addTutorialCommand() {
// Check command line arguments
if len(os.Args) > 1 && os.Args[1] == "tutorial" {
runTutorial()
os.Exit(0)
}
}
// GetVMTutorial returns the VM handler tutorial text
func GetVMTutorial() string {
return VMTutorial
}

View File

@@ -0,0 +1,285 @@
package main
import (
"fmt"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/handlerfactory"
)
// VMHandler handles VM-related actions
type VMHandler struct {
handlerfactory.BaseHandler
vms map[string]*VM
}
// VM represents a virtual machine
type VM struct {
Name string
CPU int
Memory string
Storage string
Description string
Running bool
Disks []Disk
}
// Disk represents a disk attached to a VM
type Disk struct {
Size string
Type string
}
// NewVMHandler creates a new VM handler
func NewVMHandler() *VMHandler {
return &VMHandler{
BaseHandler: handlerfactory.BaseHandler{
ActorName: "vm",
},
vms: make(map[string]*VM),
}
}
// Define handles the vm.define action
func (h *VMHandler) Define(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
name := params.Get("name")
if name == "" {
return "Error: VM name is required"
}
// Check if VM already exists
if _, exists := h.vms[name]; exists {
return fmt.Sprintf("Error: VM '%s' already exists", name)
}
// Create new VM
cpu := params.GetIntDefault("cpu", 1)
memory := params.Get("memory")
if memory == "" {
memory = "1GB"
}
storage := params.Get("storage")
if storage == "" {
storage = "10GB"
}
description := params.Get("description")
vm := &VM{
Name: name,
CPU: cpu,
Memory: memory,
Storage: storage,
Description: description,
Running: false,
Disks: []Disk{},
}
// Add VM to map
h.vms[name] = vm
return fmt.Sprintf("VM '%s' defined successfully with %d CPU, %s memory, and %s storage",
name, cpu, memory, storage)
}
// Start handles the vm.start action
func (h *VMHandler) Start(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
name := params.Get("name")
if name == "" {
return "Error: VM name is required"
}
// Find VM
vm, exists := h.vms[name]
if !exists {
return fmt.Sprintf("Error: VM '%s' not found", name)
}
// Start VM
if vm.Running {
return fmt.Sprintf("VM '%s' is already running", name)
}
vm.Running = true
return fmt.Sprintf("VM '%s' started successfully", name)
}
// Stop handles the vm.stop action
func (h *VMHandler) Stop(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
name := params.Get("name")
if name == "" {
return "Error: VM name is required"
}
// Find VM
vm, exists := h.vms[name]
if !exists {
return fmt.Sprintf("Error: VM '%s' not found", name)
}
// Stop VM
if !vm.Running {
return fmt.Sprintf("VM '%s' is already stopped", name)
}
vm.Running = false
return fmt.Sprintf("VM '%s' stopped successfully", name)
}
// DiskAdd handles the vm.disk_add action
func (h *VMHandler) DiskAdd(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
name := params.Get("name")
if name == "" {
return "Error: VM name is required"
}
// Find VM
vm, exists := h.vms[name]
if !exists {
return fmt.Sprintf("Error: VM '%s' not found", name)
}
// Add disk
size := params.Get("size")
if size == "" {
size = "10GB"
}
diskType := params.Get("type")
if diskType == "" {
diskType = "HDD"
}
disk := Disk{
Size: size,
Type: diskType,
}
vm.Disks = append(vm.Disks, disk)
return fmt.Sprintf("Added %s %s disk to VM '%s'", size, diskType, name)
}
// Delete handles the vm.delete action
func (h *VMHandler) Delete(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
name := params.Get("name")
if name == "" {
return "Error: VM name is required"
}
// Find VM
vm, exists := h.vms[name]
if !exists {
return fmt.Sprintf("Error: VM '%s' not found", name)
}
// Check if VM is running and force flag is not set
if vm.Running && !params.GetBool("force") {
return fmt.Sprintf("Error: VM '%s' is running. Use force:true to delete anyway", name)
}
// Delete VM
delete(h.vms, name)
return fmt.Sprintf("VM '%s' deleted successfully", name)
}
// List handles the vm.list action
func (h *VMHandler) List(script string) string {
if len(h.vms) == 0 {
return "No VMs defined"
}
var result strings.Builder
result.WriteString("Defined VMs:\n")
for _, vm := range h.vms {
status := "stopped"
if vm.Running {
status = "running"
}
result.WriteString(fmt.Sprintf("- %s (%s): %d CPU, %s memory, %s storage\n",
vm.Name, status, vm.CPU, vm.Memory, vm.Storage))
if len(vm.Disks) > 0 {
result.WriteString(" Attached disks:\n")
for i, disk := range vm.Disks {
result.WriteString(fmt.Sprintf(" %d. %s %s\n", i+1, disk.Size, disk.Type))
}
}
}
return result.String()
}
// Help handles the vm.help action
func (h *VMHandler) Help(script string) string {
return GetVMTutorial()
}
// Status handles the vm.status action
func (h *VMHandler) Status(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
name := params.Get("name")
if name == "" {
return "Error: VM name is required"
}
// Find VM
vm, exists := h.vms[name]
if !exists {
return fmt.Sprintf("Error: VM '%s' not found", name)
}
// Return VM status
status := "stopped"
if vm.Running {
status = "running"
}
var result strings.Builder
result.WriteString(fmt.Sprintf("VM '%s' status:\n", name))
result.WriteString(fmt.Sprintf("- Status: %s\n", status))
result.WriteString(fmt.Sprintf("- CPU: %d\n", vm.CPU))
result.WriteString(fmt.Sprintf("- Memory: %s\n", vm.Memory))
result.WriteString(fmt.Sprintf("- Storage: %s\n", vm.Storage))
if vm.Description != "" {
result.WriteString(fmt.Sprintf("- Description: %s\n", vm.Description))
}
if len(vm.Disks) > 0 {
result.WriteString("- Attached disks:\n")
for i, disk := range vm.Disks {
result.WriteString(fmt.Sprintf(" %d. %s %s\n", i+1, disk.Size, disk.Type))
}
}
return result.String()
}

View File

@@ -0,0 +1,75 @@
package main
import (
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"git.ourworld.tf/herocode/heroagent/pkg/handlerfactory"
)
// The tutorial functions are defined in tutorial.go
func main() {
// Check if tutorial mode is requested
addTutorialCommand()
fmt.Println("Starting VM Handler Example")
// Create a new handler factory
factory := handlerfactory.NewHandlerFactory()
// Create and register the VM handler
vmHandler := NewVMHandler()
err := factory.RegisterHandler(vmHandler)
if err != nil {
log.Fatalf("Failed to register VM handler: %v", err)
}
// Create a telnet server with the handler factory
server := handlerfactory.NewTelnetServer(factory, "1234")
// Create socket directory if it doesn't exist
socketDir := "/tmp"
err = os.MkdirAll(socketDir, 0755)
if err != nil {
log.Fatalf("Failed to create socket directory: %v", err)
}
// Start the telnet server on a Unix socket
socketPath := filepath.Join(socketDir, "vmhandler.sock")
err = server.Start(socketPath)
if err != nil {
log.Fatalf("Failed to start telnet server: %v", err)
}
fmt.Printf("Telnet server started on socket: %s\n", socketPath)
fmt.Printf("Connect with: nc -U %s\n", socketPath)
// Also start on TCP port for easier access
err = server.StartTCP("localhost:8024")
if err != nil {
log.Fatalf("Failed to start TCP telnet server: %v", err)
}
fmt.Println("Telnet server started on TCP: localhost:8024")
fmt.Println("Connect with: telnet localhost 8024")
// Print available commands
fmt.Println("\nVM Handler started. Type '!!vm.help' to see available commands.")
fmt.Println("Authentication secret: 1234")
// Wait for interrupt signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
// Stop the server
fmt.Println("Stopping server...")
err = server.Stop()
if err != nil {
log.Fatalf("Failed to stop telnet server: %v", err)
}
fmt.Println("Telnet server stopped")
}

View File

@@ -0,0 +1,143 @@
# Handler Factory Module
The Handler Factory module provides a framework for creating and managing handlers that process HeroScript commands through a telnet interface. It allows for both Unix socket and TCP connections, making it flexible for different use cases.
## Overview
The Handler Factory module consists of several components:
1. **HandlerFactory**: Core component that manages a collection of handlers, each responsible for processing specific actor commands.
2. **TelnetServer**: Provides a telnet interface for interacting with the handlers, supporting both Unix socket and TCP connections.
3. **HeroHandler**: Main handler that initializes and manages the HandlerFactory and TelnetServer.
4. **ProcessManagerHandler**: Example handler implementation that manages processes through HeroScript commands.
## Architecture
The module follows a plugin-based architecture where:
- The `HandlerFactory` maintains a registry of handlers
- Each handler implements the `Handler` interface and is responsible for a specific actor
- The `TelnetServer` provides a communication interface to send HeroScript commands
- HeroScript commands are parsed and routed to the appropriate handler based on the actor name
## Connecting to the Handler Factory
The Handler Factory exposes two interfaces for communication:
1. Unix Socket (default: `/tmp/hero.sock`)
2. TCP Port (default: `localhost:8023`)
to get started
```bash
cd /root/code/github/freeflowuniverse/herocode/heroagent/pkg/handlerfactory/herohandler/cmd
go run .
```
### Using Telnet to Connect
You can use the standard telnet client to connect to the TCP port:
```bash
# Connect to the default TCP port
telnet localhost 8023
# Once connected, you can send HeroScript commands
# For example:
!!process.list
```
### Using Netcat to Connect
Netcat (nc) can be used to connect to both the Unix socket and TCP port:
#### Connecting to TCP Port
```bash
# Connect to the TCP port
nc localhost 8023
# Send HeroScript commands
!!process.list
```
#### Connecting to Unix Socket
```bash
# Connect to the Unix socket
nc -U /tmp/hero.sock
# Send HeroScript commands
!!process.list
```
## HeroScript Command Format
Commands follow the HeroScript format:
```
!!actor.action param1:"value1" param2:"value2"
```
For example:
```
!!process.start name:"web_server" command:"python -m http.server 8080" log:true
!!process.status name:"web_server"
!!process.stop name:"web_server"
```
## Available Commands
The Handler Factory comes with a built-in ProcessManagerHandler that supports the following commands:
- `!!process.start` - Start a new process
- `!!process.stop` - Stop a running process
- `!!process.restart` - Restart a process
- `!!process.delete` - Delete a process
- `!!process.list` - List all processes
- `!!process.status` - Get status of a specific process
- `!!process.logs` - Get logs of a specific process
- `!!process.help` - Show help information
You can get help on available commands by typing `!!help` in the telnet/netcat session.
## Authentication
The telnet server supports optional authentication with secrets. If secrets are provided when starting the server, clients will need to authenticate using one of these secrets before they can execute commands.
## Extending with Custom Handlers
You can extend the functionality by implementing your own handlers:
1. Create a new handler that implements the `Handler` interface
2. Register the handler with the HandlerFactory
3. Access the handler's functionality through the telnet interface
## Example Usage
Here's a complete example of connecting and using the telnet interface:
```bash
# Connect to the telnet server
nc localhost 8023
# List all processes
!!process.list
# Start a new process
!!process.start name:"web_server" command:"python -m http.server 8080"
# Check the status of the process
!!process.status name:"web_server"
# View the logs
!!process.logs name:"web_server" lines:50
# Stop the process
!!process.stop name:"web_server"
```
## Implementation Details
The Handler Factory module is implemented in pure Go and follows the Go project structure conventions. It uses standard Go libraries for networking and does not have external dependencies for its core functionality.

View File

@@ -0,0 +1,95 @@
package core
import (
"fmt"
"reflect"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/paramsparser"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
)
// Handler interface defines methods that all handlers must implement
type Handler interface {
GetActorName() string
Play(script string, handler interface{}) (string, error)
}
// BaseHandler provides common functionality for all handlers
type BaseHandler struct {
ActorName string
}
// GetActorName returns the actor name for this handler
func (h *BaseHandler) GetActorName() string {
return h.ActorName
}
// Play processes all actions for this handler's actor
func (h *BaseHandler) Play(script string, handler interface{}) (string, error) {
pb, err := playbook.NewFromText(script)
if err != nil {
return "", fmt.Errorf("failed to parse heroscript: %v", err)
}
// Find all actions for this actor
actions, err := pb.FindActions(0, h.ActorName, "", playbook.ActionTypeUnknown)
if err != nil {
return "", fmt.Errorf("failed to find actions: %v", err)
}
if len(actions) == 0 {
return "", fmt.Errorf("no actions found for actor: %s", h.ActorName)
}
var results []string
// Process each action
for _, action := range actions {
// Convert action name to method name (e.g., "disk_add" -> "DiskAdd")
methodName := convertToMethodName(action.Name)
// Get the method from the handler
method := reflect.ValueOf(handler).MethodByName(methodName)
if !method.IsValid() {
return "", fmt.Errorf("action not supported: %s.%s", h.ActorName, action.Name)
}
// Call the method with the action's heroscript
actionScript := action.HeroScript()
args := []reflect.Value{reflect.ValueOf(actionScript)}
result := method.Call(args)
// Get the result
if len(result) > 0 {
resultStr := result[0].String()
results = append(results, resultStr)
}
}
return strings.Join(results, "\n"), nil
}
// ParseParams parses parameters from a heroscript action
func (h *BaseHandler) ParseParams(script string) (*paramsparser.ParamsParser, error) {
pb, err := playbook.NewFromText(script)
if err != nil {
return nil, fmt.Errorf("failed to parse heroscript: %v", err)
}
// Get the first action
if len(pb.Actions) == 0 {
return nil, fmt.Errorf("no actions found in script")
}
// Get the first action
action := pb.Actions[0]
// Check if the action is for this handler
if action.Actor != h.ActorName {
return nil, fmt.Errorf("action actor '%s' does not match handler actor '%s'", action.Actor, h.ActorName)
}
// The action already has a ParamsParser, so we can just return it
return action.Params, nil
}

View File

@@ -0,0 +1,145 @@
package core
import (
"fmt"
"reflect"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
)
// HandlerFactory manages a collection of handlers
type HandlerFactory struct {
handlers map[string]Handler
}
// NewHandlerFactory creates a new handler factory
func NewHandlerFactory() *HandlerFactory {
return &HandlerFactory{
handlers: make(map[string]Handler),
}
}
// RegisterHandler registers a handler with the factory
func (f *HandlerFactory) RegisterHandler(handler Handler) error {
actorName := handler.GetActorName()
if actorName == "" {
return fmt.Errorf("handler has no actor name")
}
if _, exists := f.handlers[actorName]; exists {
return fmt.Errorf("handler for actor '%s' already registered", actorName)
}
f.handlers[actorName] = handler
return nil
}
// GetHandler returns a handler for the specified actor
func (f *HandlerFactory) GetHandler(actorName string) (Handler, error) {
handler, exists := f.handlers[actorName]
if !exists {
return nil, fmt.Errorf("no handler registered for actor: %s", actorName)
}
return handler, nil
}
// ProcessHeroscript processes a heroscript coming from the RPC server
func (f *HandlerFactory) ProcessHeroscript(script string) (string, error) {
pb, err := playbook.NewFromText(script)
if err != nil {
return "", fmt.Errorf("failed to parse heroscript: %v", err)
}
if len(pb.Actions) == 0 {
return "", fmt.Errorf("no actions found in script")
}
// Group actions by actor
actorActions := make(map[string][]*playbook.Action)
for _, action := range pb.Actions {
actorActions[action.Actor] = append(actorActions[action.Actor], action)
}
var results []string
// Process actions for each actor
for actorName, actions := range actorActions {
handler, err := f.GetHandler(actorName)
if err != nil {
return "", err
}
// Create a playbook with just this actor's actions
actorPB := playbook.New()
for _, action := range actions {
actorAction := actorPB.NewAction(action.CID, action.Name, action.Actor, action.Priority, action.ActionType)
actorAction.Params = action.Params
}
// Process the actions
result, err := handler.Play(actorPB.HeroScript(true), handler)
if err != nil {
return "", err
}
results = append(results, result)
}
return strings.Join(results, "\n"), nil
}
// GetSupportedActions returns a map of supported actions for each registered actor
func (f *HandlerFactory) GetSupportedActions() map[string][]string {
result := make(map[string][]string)
for actorName, handler := range f.handlers {
handlerType := reflect.TypeOf(handler)
// Get all methods of the handler
var methods []string
for i := 0; i < handlerType.NumMethod(); i++ {
method := handlerType.Method(i)
// Skip methods from BaseHandler and other non-action methods
if method.Name == "GetActorName" || method.Name == "Play" || method.Name == "ParseParams" {
continue
}
// Convert method name to action name (e.g., "DiskAdd" -> "disk_add")
actionName := convertToActionName(method.Name)
methods = append(methods, actionName)
}
result[actorName] = methods
}
return result
}
// Helper functions for name conversion
// convertToMethodName converts an action name to a method name
// e.g., "disk_add" -> "DiskAdd"
func convertToMethodName(actionName string) string {
parts := strings.Split(actionName, "_")
for i, part := range parts {
if len(part) > 0 {
parts[i] = strings.ToUpper(part[0:1]) + part[1:]
}
}
return strings.Join(parts, "")
}
// convertToActionName converts a method name to an action name
// e.g., "DiskAdd" -> "disk_add"
func convertToActionName(methodName string) string {
var result strings.Builder
for i, char := range methodName {
if i > 0 && 'A' <= char && char <= 'Z' {
result.WriteRune('_')
}
result.WriteRune(char)
}
return strings.ToLower(result.String())
}

View File

@@ -0,0 +1,629 @@
package core
import (
"bufio"
"context"
"fmt"
"net"
"os"
"os/signal"
"reflect"
"strings"
"sync"
"syscall"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
)
// ANSI color codes for terminal output
const (
ColorReset = "\033[0m"
ColorRed = "\033[31m"
ColorGreen = "\033[32m"
ColorYellow = "\033[33m"
ColorBlue = "\033[34m"
ColorPurple = "\033[35m"
ColorCyan = "\033[36m"
ColorWhite = "\033[37m"
Bold = "\033[1m"
)
// TelnetServer represents a telnet server for processing HeroScript commands
type TelnetServer struct {
factory *HandlerFactory
secrets []string
unixListener net.Listener
tcpListener net.Listener
clients map[net.Conn]bool // map of client connections to authentication status
clientsMutex sync.RWMutex
running bool
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
sigCh chan os.Signal
onShutdown func()
// Map to store client preferences (like json formatting)
clientPrefs map[net.Conn]map[string]bool
prefsMutex sync.RWMutex
}
// NewTelnetServer creates a new telnet server
func NewTelnetServer(factory *HandlerFactory, secrets ...string) *TelnetServer {
ctx, cancel := context.WithCancel(context.Background())
return &TelnetServer{
factory: factory,
secrets: secrets,
clients: make(map[net.Conn]bool),
clientPrefs: make(map[net.Conn]map[string]bool),
running: false,
ctx: ctx,
cancel: cancel,
sigCh: make(chan os.Signal, 1),
onShutdown: func() {},
}
}
// Start starts the telnet server on a Unix socket
func (ts *TelnetServer) Start(socketPath string) error {
// Remove existing socket file if it exists
if err := os.Remove(socketPath); err != nil {
// Ignore error if the file doesn't exist
if !os.IsNotExist(err) {
return fmt.Errorf("failed to remove existing socket: %v", err)
}
}
// Create Unix domain socket
listener, err := net.Listen("unix", socketPath)
if err != nil {
return fmt.Errorf("failed to listen on socket: %v", err)
}
ts.unixListener = listener
ts.running = true
// Accept connections in a goroutine
ts.wg.Add(1)
go ts.acceptConnections(listener)
// Setup signal handling if this is the first listener
if ts.unixListener != nil && ts.tcpListener == nil {
ts.setupSignalHandling()
}
return nil
}
// StartTCP starts the telnet server on a TCP port
func (ts *TelnetServer) StartTCP(address string) error {
// Create TCP listener
listener, err := net.Listen("tcp", address)
if err != nil {
return fmt.Errorf("failed to listen on TCP address: %v", err)
}
ts.tcpListener = listener
ts.running = true
// Accept connections in a goroutine
ts.wg.Add(1)
go ts.acceptConnections(listener)
// Setup signal handling if this is the first listener
if ts.tcpListener != nil && ts.unixListener == nil {
ts.setupSignalHandling()
}
return nil
}
// Stop stops the telnet server
func (ts *TelnetServer) Stop() error {
if !ts.running {
return nil
}
ts.running = false
// Signal all goroutines to stop
ts.cancel()
// Close the listeners
if ts.unixListener != nil {
if err := ts.unixListener.Close(); err != nil {
return fmt.Errorf("failed to close Unix listener: %v", err)
}
}
if ts.tcpListener != nil {
if err := ts.tcpListener.Close(); err != nil {
return fmt.Errorf("failed to close TCP listener: %v", err)
}
}
// Close all client connections
ts.clientsMutex.Lock()
for conn := range ts.clients {
conn.Close()
delete(ts.clients, conn)
}
ts.clientsMutex.Unlock()
// Wait for all goroutines to finish
ts.wg.Wait()
// Call the onShutdown callback if set
if ts.onShutdown != nil {
ts.onShutdown()
}
return nil
}
// acceptConnections accepts incoming connections
func (ts *TelnetServer) acceptConnections(listener net.Listener) {
defer ts.wg.Done()
for {
// Use a separate goroutine to accept connections so we can check for context cancellation
connCh := make(chan net.Conn)
errCh := make(chan error)
go func() {
conn, err := listener.Accept()
if err != nil {
errCh <- err
return
}
connCh <- conn
}()
select {
case <-ts.ctx.Done():
// Context was canceled, exit the loop
return
case conn := <-connCh:
// Handle the connection in a goroutine
ts.wg.Add(1)
go ts.handleConnection(conn)
case err := <-errCh:
if ts.running {
fmt.Printf("Failed to accept connection: %v\n", err)
} else {
// If we're not running, this is expected during shutdown
return
}
}
}
}
// handleConnection handles a client connection
func (ts *TelnetServer) handleConnection(conn net.Conn) {
defer ts.wg.Done()
// Add client to the map (not authenticated yet)
ts.clientsMutex.Lock()
ts.clients[conn] = false
ts.clientsMutex.Unlock()
// Initialize client preferences
ts.prefsMutex.Lock()
ts.clientPrefs[conn] = make(map[string]bool)
ts.prefsMutex.Unlock()
// Ensure client is removed when connection closes
defer func() {
conn.Close()
ts.clientsMutex.Lock()
delete(ts.clients, conn)
ts.clientsMutex.Unlock()
// Also remove client preferences
ts.prefsMutex.Lock()
delete(ts.clientPrefs, conn)
ts.prefsMutex.Unlock()
}()
// Welcome message
if len(ts.secrets) > 0 {
conn.Write([]byte(" ** Welcome: you are not authenticated, please authenticate with !!core.auth secret:'your_secret'\n"))
} else {
conn.Write([]byte(" ** Welcome to HeroLauncher Telnet Server\n ** Note: Press Enter twice after sending heroscript to execute\n"))
}
// Create a scanner for reading input
scanner := bufio.NewScanner(conn)
var heroscriptBuffer strings.Builder
commandHistory := []string{}
historyPos := 0
interactiveMode := true
// Process client input
for scanner.Scan() {
line := scanner.Text()
// Check for Ctrl+C (ASCII value 3)
if line == "\x03" {
conn.Write([]byte("Goodbye!\n"))
return
}
// Check for arrow up (ANSI escape sequence for up arrow: "\x1b[A")
if line == "\x1b[A" && len(commandHistory) > 0 {
if historyPos > 0 {
historyPos--
}
if historyPos < len(commandHistory) {
conn.Write([]byte(commandHistory[historyPos]))
line = commandHistory[historyPos]
}
}
// Handle quit/exit commands
if line == "!!quit" || line == "!!exit" || line == "q" {
conn.Write([]byte("Goodbye!\n"))
return
}
// Handle help command
if line == "!!help" || line == "h" || line == "?" {
helpText := ts.generateHelpText(interactiveMode)
conn.Write([]byte(helpText))
continue
}
// Handle interactive mode toggle
if line == "!!interactive" || line == "!!i" || line == "i" {
interactiveMode = !interactiveMode
if interactiveMode {
// Only use colors in terminal output, not in telnet
fmt.Println(ColorGreen + "Interactive mode enabled for client. Using colors for console output." + ColorReset)
conn.Write([]byte("Interactive mode enabled. Using formatted output.\n"))
} else {
fmt.Println("Interactive mode disabled for client. Plain text console output.")
conn.Write([]byte("Interactive mode disabled. Plain text output.\n"))
}
continue
}
// Handle JSON format toggle
if line == "!!json" {
ts.prefsMutex.Lock()
prefs, exists := ts.clientPrefs[conn]
if !exists {
prefs = make(map[string]bool)
ts.clientPrefs[conn] = prefs
}
// Toggle JSON format preference
currentSetting := prefs["json"]
prefs["json"] = !currentSetting
ts.prefsMutex.Unlock()
if prefs["json"] {
conn.Write([]byte("JSON format will be automatically added to all heroscripts.\n"))
} else {
conn.Write([]byte("JSON format will no longer be automatically added to heroscripts.\n"))
}
continue
}
// Check authentication
isAuthenticated := ts.isClientAuthenticated(conn)
// Handle authentication
if !isAuthenticated {
// Check if this is an auth command
if strings.HasPrefix(strings.TrimSpace(line), "!!core.auth") || strings.HasPrefix(strings.TrimSpace(line), "!!auth") {
pb, err := playbook.NewFromText(line)
if err != nil {
conn.Write([]byte("Authentication syntax error. Use !!core.auth secret:'your_secret'\n"))
continue
}
if len(pb.Actions) > 0 {
action := pb.Actions[0]
// Support both auth.auth and core.auth patterns
validActor := action.Actor == "auth" || action.Actor == "core"
validAction := action.Name == "auth"
if validActor && validAction {
secret := action.Params.Get("secret")
if ts.isValidSecret(secret) {
ts.clientsMutex.Lock()
ts.clients[conn] = true
ts.clientsMutex.Unlock()
conn.Write([]byte(" ** Authentication successful. You can now send commands.\n"))
continue
} else {
conn.Write([]byte("Authentication failed: Invalid secret provided.\n"))
continue
}
}
}
conn.Write([]byte("Invalid authentication format. Use !!core.auth secret:'your_secret'\n"))
} else {
conn.Write([]byte("You must authenticate first. Use !!core.auth secret:'your_secret'\n"))
}
continue
}
// Empty line executes pending command but does not repeat last command
if line == "" {
if heroscriptBuffer.Len() > 0 {
// Execute pending command
commandText := heroscriptBuffer.String()
result := ts.executeHeroscript(commandText, conn, interactiveMode)
conn.Write([]byte(result))
// Add to history
commandHistory = append(commandHistory, commandText)
historyPos = len(commandHistory)
// Reset buffer
heroscriptBuffer.Reset()
}
continue
}
// Add line to heroscript buffer
if heroscriptBuffer.Len() > 0 {
heroscriptBuffer.WriteString("\n")
}
heroscriptBuffer.WriteString(line)
}
// Handle scanner errors
if err := scanner.Err(); err != nil {
fmt.Printf("Error reading from connection: %v\n", err)
}
}
// isClientAuthenticated checks if a client is authenticated
func (ts *TelnetServer) isClientAuthenticated(conn net.Conn) bool {
// If no secrets are configured, authentication is not required
if len(ts.secrets) == 0 {
return true
}
ts.clientsMutex.RLock()
defer ts.clientsMutex.RUnlock()
authenticated, exists := ts.clients[conn]
return exists && authenticated
}
// isValidSecret checks if a secret is valid
func (ts *TelnetServer) isValidSecret(secret string) bool {
for _, validSecret := range ts.secrets {
if secret == validSecret {
return true
}
}
return false
}
// connKey is a type for context keys
type connKey struct{}
// connKeyValue is the key for storing the connection in context
var connKeyValue = connKey{}
// executeHeroscript executes a heroscript and returns the result
func (ts *TelnetServer) executeHeroscript(script string, conn net.Conn, interactive bool) string {
// Check if this connection has JSON formatting enabled
if conn != nil {
ts.prefsMutex.RLock()
prefs, exists := ts.clientPrefs[conn]
ts.prefsMutex.RUnlock()
if exists && prefs["json"] {
// Add format:json if not already present
if !strings.Contains(script, "format:json") {
script = ts.addJsonFormat(script)
}
}
}
if interactive {
// Format the script with colors
formattedScript := formatHeroscript(script)
fmt.Println("Executing heroscript:\n" + formattedScript)
} else {
fmt.Println("Executing heroscript:\n" + script)
}
// Process the heroscript
result, err := ts.factory.ProcessHeroscript(script)
if err != nil {
errorMsg := fmt.Sprintf("Error: %v", err)
if interactive {
// Only use colors in terminal output, not in telnet response
fmt.Println(ColorRed + errorMsg + ColorReset)
}
return errorMsg
}
if interactive {
// Only use colors in terminal output, not in telnet response
fmt.Println(ColorGreen + "Result: " + result + ColorReset)
}
return result
}
// addJsonFormat adds format:json to a heroscript if not already present
func (ts *TelnetServer) addJsonFormat(script string) string {
lines := strings.Split(script, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "!!") {
// Found action line, add format:json if not present
if !strings.Contains(line, "format:") {
lines[i] = line + " format:json"
}
}
}
return strings.Join(lines, "\n")
}
// formatHeroscript formats heroscript with colors for console output only
// This is not used for telnet responses, only for server-side logging
func formatHeroscript(script string) string {
var formatted strings.Builder
lines := strings.Split(script, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Comments
if strings.HasPrefix(trimmed, "//") {
formatted.WriteString(ColorBlue + line + ColorReset + "\n")
continue
}
// Action lines
if strings.HasPrefix(trimmed, "!") {
parts := strings.SplitN(trimmed, " ", 2)
actionPart := parts[0]
// Highlight actor.action
formatted.WriteString(Bold + ColorYellow + actionPart + ColorReset)
// Add the rest of the line
if len(parts) > 1 {
formatted.WriteString(" " + parts[1])
}
formatted.WriteString("\n")
continue
}
// Parameter lines
if strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
// Parameter name
formatted.WriteString(parts[0] + ":")
// Parameter value
value := parts[1]
if strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'") {
formatted.WriteString(ColorCyan + value + ColorReset + "\n")
} else {
formatted.WriteString(ColorPurple + value + ColorReset + "\n")
}
continue
}
}
// Default formatting
formatted.WriteString(line + "\n")
}
return formatted.String()
}
// generateHelpText generates help text for available commands
// EnableSignalHandling sets up signal handling for graceful shutdown
// This is now deprecated as signal handling is automatically set up when the server starts
// It's kept for backward compatibility
func (ts *TelnetServer) EnableSignalHandling(onShutdown func()) {
// Set the onShutdown callback
ts.onShutdown = onShutdown
// Setup the signal handling
ts.setupSignalHandling()
}
// setupSignalHandling sets up signal handling for graceful shutdown
func (ts *TelnetServer) setupSignalHandling() {
// Reset any previous signal notification
signal.Reset(syscall.SIGINT, syscall.SIGTERM)
// Register for SIGINT and SIGTERM signals
signal.Notify(ts.sigCh, syscall.SIGINT, syscall.SIGTERM)
// Start a goroutine to handle signals
ts.wg.Add(1)
go func() {
defer ts.wg.Done()
// Wait for signal
sig := <-ts.sigCh
// Log that we received a signal
fmt.Printf("Received %s signal, shutting down telnet server...\n", sig)
// Stop the telnet server
if err := ts.Stop(); err != nil {
fmt.Printf("Error stopping telnet server: %v\n", err)
} else {
fmt.Println("Telnet server stopped successfully")
}
// Call the onShutdown callback if set
if ts.onShutdown != nil {
ts.onShutdown()
}
// Exit the program if this was triggered by a signal
os.Exit(0)
}()
}
func (ts *TelnetServer) generateHelpText(interactive bool) string {
var help strings.Builder
// Only use colors in console output, not in telnet
if interactive {
fmt.Println(Bold + ColorCyan + "Generating help text for client" + ColorReset)
}
help.WriteString("Available Commands:\n")
// System commands
help.WriteString(" System Commands:\n")
help.WriteString(" !!help, h, ? - Show this help\n")
help.WriteString(" !!interactive, i - Toggle interactive mode\n")
help.WriteString(" !!json - Toggle automatic JSON formatting for heroscripts\n")
help.WriteString(" !!quit, q - Disconnect\n")
help.WriteString(" !!exit - Disconnect\n")
help.WriteString("\n")
// Authentication
help.WriteString(" Authentication:\n")
help.WriteString(" !!core.auth secret:'your_secret' - Authenticate with a secret\n")
help.WriteString("\n")
// Usage tips
help.WriteString(" Usage Tips:\n")
help.WriteString(" - Enter an empty line to execute a command\n")
help.WriteString(" - Commands can span multiple lines\n")
help.WriteString(" - Use arrow up to access command history\n")
help.WriteString("------------------------------------------------\n\n")
// Handler help sections
help.WriteString("Handler Documentation:\n\n")
// Get all registered handlers
for actorName, handler := range ts.factory.handlers {
// Try to call the Help method on each handler using reflection
handlerValue := reflect.ValueOf(handler)
helpMethod := handlerValue.MethodByName("Help")
if helpMethod.IsValid() {
// Call the Help method
args := []reflect.Value{reflect.ValueOf("")}
result := helpMethod.Call(args)
// Get the result
if len(result) > 0 && result[0].Kind() == reflect.String {
helpText := result[0].String()
help.WriteString(fmt.Sprintf(" %s Handler (%s):\n", strings.Title(actorName), actorName))
help.WriteString(fmt.Sprintf(" %s\n", helpText))
help.WriteString("\n")
}
}
}
return help.String()
}

View File

@@ -0,0 +1,63 @@
package handlers
import (
"fmt"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/core"
)
// AuthHandler handles authentication actions
type AuthHandler struct {
BaseHandler
secrets []string
}
// NewAuthHandler creates a new authentication handler
func NewAuthHandler(secrets ...string) *AuthHandler {
return &AuthHandler{
BaseHandler: BaseHandler{
BaseHandler: core.BaseHandler{
ActorName: "auth",
},
},
secrets: secrets,
}
}
// Auth handles the auth.auth action
func (h *AuthHandler) Auth(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
secret := params.Get("secret")
if secret == "" {
return "Error: secret is required"
}
for _, validSecret := range h.secrets {
if secret == validSecret {
return "Authentication successful"
}
}
return "Authentication failed: invalid secret"
}
// AddSecret adds a new secret to the handler
func (h *AuthHandler) AddSecret(secret string) {
h.secrets = append(h.secrets, secret)
}
// RemoveSecret removes a secret from the handler
func (h *AuthHandler) RemoveSecret(secret string) bool {
for i, s := range h.secrets {
if s == secret {
// Remove the secret
h.secrets = append(h.secrets[:i], h.secrets[i+1:]...)
return true
}
}
return false
}

View File

@@ -0,0 +1,103 @@
package handlers
import (
"fmt"
"reflect"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/core"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/paramsparser"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
)
// BaseHandler provides common functionality for all handlers
type BaseHandler struct {
core.BaseHandler
}
// Play processes all actions for this handler's actor
func (h *BaseHandler) Play(script string, handler interface{}) (string, error) {
pb, err := playbook.NewFromText(script)
if err != nil {
return "", fmt.Errorf("failed to parse heroscript: %v", err)
}
// Find all actions for this actor
actions, err := pb.FindActions(0, h.GetActorName(), "", playbook.ActionTypeUnknown)
if err != nil {
return "", fmt.Errorf("failed to find actions: %v", err)
}
if len(actions) == 0 {
return "", fmt.Errorf("no actions found for actor: %s", h.GetActorName())
}
var results []string
// Process each action
for _, action := range actions {
// Convert action name to method name (e.g., "disk_add" -> "DiskAdd")
methodName := convertToMethodName(action.Name)
// Get the method from the handler
method := reflect.ValueOf(handler).MethodByName(methodName)
if !method.IsValid() {
return "", fmt.Errorf("action not supported: %s.%s", h.GetActorName(), action.Name)
}
// Call the method with the action's heroscript
actionScript := action.HeroScript()
args := []reflect.Value{reflect.ValueOf(actionScript)}
result := method.Call(args)
// Get the result
if len(result) > 0 {
resultStr := result[0].String()
results = append(results, resultStr)
}
}
return strings.Join(results, "\n"), nil
}
// ParseParams parses parameters from a heroscript action
func (h *BaseHandler) ParseParams(script string) (*paramsparser.ParamsParser, error) {
pb, err := playbook.NewFromText(script)
if err != nil {
return nil, fmt.Errorf("failed to parse heroscript: %v", err)
}
// Get the first action
if len(pb.Actions) == 0 {
return nil, fmt.Errorf("no actions found in script")
}
return pb.Actions[0].Params, nil
}
// Helper functions for name conversion
// convertToMethodName converts an action name to a method name
// e.g., "disk_add" -> "DiskAdd"
func convertToMethodName(actionName string) string {
parts := strings.Split(actionName, "_")
for i, part := range parts {
if len(part) > 0 {
parts[i] = strings.ToUpper(part[0:1]) + part[1:]
}
}
return strings.Join(parts, "")
}
// convertToActionName converts a method name to an action name
// e.g., "DiskAdd" -> "disk_add"
func convertToActionName(methodName string) string {
var result strings.Builder
for i, char := range methodName {
if i > 0 && 'A' <= char && char <= 'Z' {
result.WriteRune('_')
}
result.WriteRune(char)
}
return strings.ToLower(result.String())
}

View File

@@ -0,0 +1,115 @@
package handlers
import (
"fmt"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/core"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
)
// HandlerFactory manages a collection of handlers for processing HeroScript commands
type HandlerFactory struct {
handlers map[string]core.Handler
}
// NewHandlerFactory creates a new handler factory
func NewHandlerFactory() *HandlerFactory {
return &HandlerFactory{
handlers: make(map[string]core.Handler),
}
}
// RegisterHandler registers a handler with the factory
func (f *HandlerFactory) RegisterHandler(handler core.Handler) error {
actorName := handler.GetActorName()
if actorName == "" {
return fmt.Errorf("handler has no actor name")
}
if _, exists := f.handlers[actorName]; exists {
return fmt.Errorf("handler for actor '%s' already registered", actorName)
}
f.handlers[actorName] = handler
return nil
}
// GetHandler returns a handler for the specified actor
func (f *HandlerFactory) GetHandler(actorName string) (core.Handler, error) {
handler, exists := f.handlers[actorName]
if !exists {
return nil, fmt.Errorf("no handler registered for actor: %s", actorName)
}
return handler, nil
}
// ProcessHeroscript processes a heroscript command
func (f *HandlerFactory) ProcessHeroscript(script string) (string, error) {
pb, err := playbook.NewFromText(script)
if err != nil {
return "", fmt.Errorf("failed to parse heroscript: %v", err)
}
if len(pb.Actions) == 0 {
return "", fmt.Errorf("no actions found in script")
}
// Group actions by actor
actorActions := make(map[string][]*playbook.Action)
for _, action := range pb.Actions {
actorActions[action.Actor] = append(actorActions[action.Actor], action)
}
var results []string
// Process actions for each actor
for actorName, actions := range actorActions {
handler, err := f.GetHandler(actorName)
if err != nil {
return "", err
}
// Create a playbook with just this actor's actions
actorPB := playbook.New()
for _, action := range actions {
actorAction := actorPB.NewAction(action.CID, action.Name, action.Actor, action.Priority, action.ActionType)
actorAction.Params = action.Params
}
// Process the actions
result, err := handler.Play(actorPB.HeroScript(true), handler)
if err != nil {
return "", err
}
results = append(results, result)
}
return strings.Join(results, "\n"), nil
}
// GetSupportedActions returns a map of supported actions for each registered actor
func (f *HandlerFactory) GetSupportedActions() map[string][]string {
result := make(map[string][]string)
for actorName, handler := range f.handlers {
// Get supported actions for this handler
actions, err := getSupportedActions(handler)
if err == nil && len(actions) > 0 {
result[actorName] = actions
}
}
return result
}
// getSupportedActions returns a list of supported actions for a handler
func getSupportedActions(handler core.Handler) ([]string, error) {
// This is a simplified implementation
// In a real implementation, you would use reflection to get all methods
// that match the pattern for action handlers
// For now, we'll return an empty list
return []string{}, nil
}

View File

@@ -0,0 +1,15 @@
package herohandler
import (
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/core"
)
// GetFactory returns the handler factory
func (h *HeroHandler) GetFactory() *core.HandlerFactory {
return h.factory
}
// RegisterHandler registers a handler with the factory
func (h *HeroHandler) RegisterHandler(handler core.Handler) error {
return h.factory.RegisterHandler(handler)
}

View File

@@ -0,0 +1,39 @@
package main
import (
"log"
"sync"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/herohandler"
)
func main() {
// Initialize the herohandler.DefaultInstance
if err := herohandler.Init(); err != nil {
log.Fatalf("Failed to initialize herohandler: %v", err)
}
// Start the telnet server on both Unix socket and TCP
socketPath := "/tmp/hero.sock"
tcpAddress := "localhost:8023"
log.Println("Starting telnet server...")
//if err := herohandler.DefaultInstance.StartTelnet(socketPath, tcpAddress, "1234");
if err := herohandler.DefaultInstance.StartTelnet(socketPath, tcpAddress); err != nil {
log.Fatalf("Failed to start telnet server: %v", err)
}
log.Println("Telnet server started successfully")
// Register a callback for when the server shuts down
herohandler.DefaultInstance.EnableSignalHandling(func() {
log.Println("Server shutdown complete")
})
log.Println("Press Ctrl+C to stop the server")
// Create a WaitGroup that never completes to keep the program running
// The signal handling in the telnet server will handle the shutdown
var wg sync.WaitGroup
wg.Add(1)
wg.Wait()
}

View File

@@ -0,0 +1,94 @@
package herohandler
import (
"fmt"
"log"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/core"
// "git.ourworld.tf/herocode/heroagent/pkg/handlerfactory/heroscript/handlerfactory/fakehandler"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/processmanagerhandler"
)
// HeroHandler is the main handler factory that manages all registered handlers
type HeroHandler struct {
factory *core.HandlerFactory
telnetServer *core.TelnetServer
}
var (
// DefaultInstance is the default HeroHandler instance
DefaultInstance *HeroHandler
)
// init initializes the default HeroHandler instance
func Init() error {
factory := core.NewHandlerFactory()
DefaultInstance = &HeroHandler{
factory: factory,
telnetServer: core.NewTelnetServer(factory),
}
log.Println("HeroHandler initialized")
// Register the process manager handler
handler := processmanagerhandler.NewProcessManagerHandler()
if handler == nil {
log.Fatalf("Failed to create process manager handler")
}
if err := DefaultInstance.factory.RegisterHandler(handler); err != nil {
log.Fatalf("Failed to register process manager handler: %v", err)
}
return nil
}
func StartTelnet() error {
if err := DefaultInstance.StartTelnet("/tmp/hero.sock", "localhost:8023"); err != nil {
log.Fatalf("Failed to start telnet server: %v", err)
}
return nil
}
// StartTelnet starts the telnet server on both Unix socket and TCP port
func (h *HeroHandler) StartTelnet(socketPath string, tcpAddress string, secrets ...string) error {
// Create a new telnet server with the factory and secrets
h.telnetServer = core.NewTelnetServer(h.factory, secrets...)
// Start Unix socket server
if socketPath != "" {
if err := h.telnetServer.Start(socketPath); err != nil {
return fmt.Errorf("failed to start Unix socket telnet server: %v", err)
}
log.Printf("Telnet server started on Unix socket: %s", socketPath)
}
// Start TCP server
if tcpAddress != "" {
if err := h.telnetServer.StartTCP(tcpAddress); err != nil {
return fmt.Errorf("failed to start TCP telnet server: %v", err)
}
log.Printf("Telnet server started on TCP address: %s", tcpAddress)
}
return nil
}
// StopTelnet stops the telnet server
func (h *HeroHandler) StopTelnet() error {
if h.telnetServer == nil {
return nil
}
return h.telnetServer.Stop()
}
// EnableSignalHandling sets up signal handling for graceful shutdown of the telnet server
func (h *HeroHandler) EnableSignalHandling(onShutdown func()) {
if h.telnetServer == nil {
return
}
h.telnetServer.EnableSignalHandling(onShutdown)
}

View File

@@ -0,0 +1,51 @@
package main
import (
"fmt"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
)
func main() {
// Example of using the process manager handler through heroscript
// Create a new playbook
pb := playbook.New()
// Start a simple process
startAction := pb.NewAction("1", "start", "process", 0, playbook.ActionTypeUnknown)
startAction.Params.Set("name", "example_process")
startAction.Params.Set("command", "ping -c 60 localhost")
startAction.Params.Set("log", "true")
// List all processes
listAction := pb.NewAction("2", "list", "process", 0, playbook.ActionTypeUnknown)
listAction.Params.Set("format", "table")
// Get status of a specific process
statusAction := pb.NewAction("3", "status", "process", 0, playbook.ActionTypeUnknown)
statusAction.Params.Set("name", "example_process")
// Get logs of a specific process
logsAction := pb.NewAction("4", "logs", "process", 0, playbook.ActionTypeUnknown)
logsAction.Params.Set("name", "example_process")
logsAction.Params.Set("lines", "10")
// Stop a process
stopAction := pb.NewAction("5", "stop", "process", 0, playbook.ActionTypeUnknown)
stopAction.Params.Set("name", "example_process")
// Generate the heroscript
script := pb.HeroScript(true)
// Print the script
fmt.Println("=== Example HeroScript for Process Manager ===")
fmt.Println(script)
fmt.Println("============================================")
fmt.Println("To use this script:")
fmt.Println("1. Start the process manager handler server")
fmt.Println("2. Connect to it using: telnet localhost 8025")
fmt.Println("3. Authenticate with: !!auth 1234")
fmt.Println("4. Copy and paste the above script")
fmt.Println("5. Or use individual commands like: !!process.start name:myprocess command:\"sleep 60\"")
}

View File

@@ -0,0 +1,221 @@
package processmanagerhandler
import (
"fmt"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/core"
"git.ourworld.tf/herocode/heroagent/pkg/processmanager"
)
// ProcessManagerHandler handles process manager-related actions
type ProcessManagerHandler struct {
core.BaseHandler
pm *processmanager.ProcessManager
}
// NewProcessManagerHandler creates a new process manager handler
func NewProcessManagerHandler() *ProcessManagerHandler {
return &ProcessManagerHandler{
BaseHandler: core.BaseHandler{
ActorName: "process",
},
pm: processmanager.NewProcessManager(), // Empty string as secret was removed from ProcessManager
}
}
// Start handles the process.start action
func (h *ProcessManagerHandler) Start(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
name := params.Get("name")
if name == "" {
return "Error: Process name is required"
}
command := params.Get("command")
if command == "" {
return "Error: Command is required"
}
logEnabled := params.GetBoolDefault("log", true)
deadline := params.GetIntDefault("deadline", 0)
cron := params.Get("cron")
jobID := params.Get("job_id")
err = h.pm.StartProcess(name, command, logEnabled, deadline, cron, jobID)
if err != nil {
return fmt.Sprintf("Error starting process: %v", err)
}
return fmt.Sprintf("Process '%s' started successfully", name)
}
// Stop handles the process.stop action
func (h *ProcessManagerHandler) Stop(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
name := params.Get("name")
if name == "" {
return "Error: Process name is required"
}
err = h.pm.StopProcess(name)
if err != nil {
return fmt.Sprintf("Error stopping process: %v", err)
}
return fmt.Sprintf("Process '%s' stopped successfully", name)
}
// Restart handles the process.restart action
func (h *ProcessManagerHandler) Restart(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
name := params.Get("name")
if name == "" {
return "Error: Process name is required"
}
err = h.pm.RestartProcess(name)
if err != nil {
return fmt.Sprintf("Error restarting process: %v", err)
}
return fmt.Sprintf("Process '%s' restarted successfully", name)
}
// Delete handles the process.delete action
func (h *ProcessManagerHandler) Delete(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
name := params.Get("name")
if name == "" {
return "Error: Process name is required"
}
err = h.pm.DeleteProcess(name)
if err != nil {
return fmt.Sprintf("Error deleting process: %v", err)
}
return fmt.Sprintf("Process '%s' deleted successfully", name)
}
// List handles the process.list action
func (h *ProcessManagerHandler) List(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
processes := h.pm.ListProcesses()
if len(processes) == 0 {
return "No processes found"
}
format := params.Get("format")
if format == "" {
format = "json"
}
output, err := processmanager.FormatProcessList(processes, format)
if err != nil {
return fmt.Sprintf("Error formatting process list: %v", err)
}
return output
}
// Status handles the process.status action
func (h *ProcessManagerHandler) Status(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
name := params.Get("name")
if name == "" {
return "Error: Process name is required"
}
procInfo, err := h.pm.GetProcessStatus(name)
if err != nil {
return fmt.Sprintf("Error getting process status: %v", err)
}
format := params.Get("format")
if format == "" {
format = "json"
}
output, err := processmanager.FormatProcessInfo(procInfo, format)
if err != nil {
return fmt.Sprintf("Error formatting process status: %v", err)
}
return output
}
// Logs handles the process.logs action
func (h *ProcessManagerHandler) Logs(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
name := params.Get("name")
if name == "" {
return "Error: Process name is required"
}
lines := params.GetIntDefault("lines", 100)
logs, err := h.pm.GetProcessLogs(name, lines)
if err != nil {
return fmt.Sprintf("Error getting process logs: %v", err)
}
return logs
}
// SetLogsPath handles the process.set_logs_path action
func (h *ProcessManagerHandler) SetLogsPath(script string) string {
params, err := h.ParseParams(script)
if err != nil {
return fmt.Sprintf("Error parsing parameters: %v", err)
}
path := params.Get("path")
if path == "" {
return "Error: Path is required"
}
h.pm.SetLogsBasePath(path)
return fmt.Sprintf("Process logs path set to '%s'", path)
}
// Help handles the process.help action
func (h *ProcessManagerHandler) Help(script string) string {
return `Process Manager Handler Commands:
process.start name:<name> command:<command> [log:true|false] [deadline:<seconds>] [cron:<cron_expr>] [job_id:<id>]
process.stop name:<name>
process.restart name:<name>
process.delete name:<name>
process.list [format:json|table|text]
process.status name:<name> [format:json|table|text]
process.logs name:<name> [lines:<count>]
process.set_logs_path path:<path>
process.help`
}

View File

@@ -0,0 +1,117 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "proc-macro2"
version = "1.0.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustclients"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "syn"
version = "2.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"

View File

@@ -0,0 +1,13 @@
[package]
name = "rustclients"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
[[example]]
name = "fakehandler_example"
path = "examples/fakehandler_example.rs"

View File

@@ -0,0 +1,111 @@
use std::time::Duration;
use std::thread;
use std::io::{Read, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::fs;
// Import directly from the lib.rs
use rustclients::FakeHandlerClient;
use rustclients::Result;
// Simple mock server that handles Unix socket connections
fn start_mock_server(socket_path: &str) -> std::thread::JoinHandle<()> {
let socket_path = socket_path.to_string();
thread::spawn(move || {
// Remove the socket file if it exists
let _ = fs::remove_file(&socket_path);
// Create a Unix socket listener
let listener = match UnixListener::bind(&socket_path) {
Ok(listener) => listener,
Err(e) => {
println!("Failed to bind to socket {}: {}", socket_path, e);
return;
}
};
println!("Mock server listening on {}", socket_path);
// Accept connections and handle them
for stream in listener.incoming() {
match stream {
Ok(mut stream) => {
println!("Mock server: Accepted new connection");
// Read from the stream
let mut buffer = [0; 1024];
match stream.read(&mut buffer) {
Ok(n) => {
let request = String::from_utf8_lossy(&buffer[0..n]);
println!("Mock server received: {}", request);
// Send a welcome message first
let welcome = "Welcome to the mock server\n";
let _ = stream.write_all(welcome.as_bytes());
// Send a response
let response = "OK: Command processed\n> ";
let _ = stream.write_all(response.as_bytes());
},
Err(e) => println!("Mock server error reading from stream: {}", e),
}
},
Err(e) => println!("Mock server error accepting connection: {}", e),
}
}
})
}
fn main() -> Result<()> {
// Define the socket path
let socket_path = "/tmp/heroagent/test.sock";
// Start the mock server
println!("Starting mock server...");
let server_handle = start_mock_server(socket_path);
// Give the server time to start
thread::sleep(Duration::from_millis(500));
// Initialize the client
let client = FakeHandlerClient::new(socket_path)
.with_timeout(Duration::from_secs(5));
println!("\n--- Test 1: Making first request ---");
// This should open a new connection
match client.return_success(Some("Test 1")) {
Ok(response) => println!("Response: {}", response),
Err(e) => println!("Error: {}", e),
}
// Wait a moment
thread::sleep(Duration::from_millis(500));
println!("\n--- Test 2: Making second request ---");
// This should open another new connection
match client.return_success(Some("Test 2")) {
Ok(response) => println!("Response: {}", response),
Err(e) => println!("Error: {}", e),
}
// Wait a moment
thread::sleep(Duration::from_millis(500));
println!("\n--- Test 3: Making third request ---");
// This should open yet another new connection
match client.return_success(Some("Test 3")) {
Ok(response) => println!("Response: {}", response),
Err(e) => println!("Error: {}", e),
}
println!("\nTest completed. Check the debug output to verify that a new connection was opened for each request.");
// Clean up
let _ = fs::remove_file(socket_path);
// Wait for the server to finish (in a real application, you might want to signal it to stop)
println!("Waiting for server to finish...");
// In a real application, we would join the server thread here
Ok(())
}

View File

@@ -0,0 +1,100 @@
use std::time::Duration;
// Import directly from the lib.rs
use rustclients::FakeHandlerClient;
use rustclients::Result;
fn main() -> Result<()> {
// Create a new fake handler client
// Replace with the actual socket path used in your environment
let socket_path = "/tmp/heroagent/fakehandler.sock";
// Initialize the client with a timeout
let client = FakeHandlerClient::new(socket_path)
.with_timeout(Duration::from_secs(5));
println!("Connecting to fake handler at {}", socket_path);
// Connect to the server
match client.connect() {
Ok(_) => println!("Successfully connected to fake handler"),
Err(e) => {
eprintln!("Failed to connect: {}", e);
eprintln!("Make sure the fake handler server is running and the socket path is correct");
return Err(e);
}
}
// Test various commands
// 1. Get help information
println!("\n--- Help Information ---");
match client.help() {
Ok(help) => println!("{}", help),
Err(e) => eprintln!("Error getting help: {}", e),
}
// 2. Return success message
println!("\n--- Success Message ---");
match client.return_success(Some("Custom success message")) {
Ok(response) => println!("Success response: {}", response),
Err(e) => eprintln!("Error getting success: {}", e),
}
// 3. Return JSON response
println!("\n--- JSON Response ---");
match client.return_json(Some("JSON message"), Some("success"), Some(200)) {
Ok(response) => println!("JSON response: {:?}", response),
Err(e) => eprintln!("Error getting JSON: {}", e),
}
// 4. Return error message (this will return a ClientError)
println!("\n--- Error Message ---");
match client.return_error(Some("Custom error message")) {
Ok(response) => println!("Error response (unexpected success): {}", response),
Err(e) => eprintln!("Expected error received: {}", e),
}
// 5. Return empty response
println!("\n--- Empty Response ---");
match client.return_empty() {
Ok(response) => println!("Empty response (length: {})", response.len()),
Err(e) => eprintln!("Error getting empty response: {}", e),
}
// 6. Return large response
println!("\n--- Large Response ---");
match client.return_large(Some(10)) {
Ok(response) => {
let lines: Vec<&str> = response.lines().collect();
println!("Large response (first 3 lines of {} total):", lines.len());
for i in 0..std::cmp::min(3, lines.len()) {
println!(" {}", lines[i]);
}
println!(" ...");
},
Err(e) => eprintln!("Error getting large response: {}", e),
}
// 7. Return invalid JSON (will cause a JSON parsing error)
println!("\n--- Invalid JSON ---");
match client.return_invalid_json() {
Ok(response) => println!("Invalid JSON response (unexpected success): {:?}", response),
Err(e) => eprintln!("Expected JSON error received: {}", e),
}
// 8. Return malformed error
println!("\n--- Malformed Error ---");
match client.return_malformed_error() {
Ok(response) => println!("Malformed error response: {}", response),
Err(e) => eprintln!("Error with malformed error: {}", e),
}
// Close the connection
println!("\nClosing connection");
client.close()?;
println!("Example completed successfully");
Ok(())
}

View File

@@ -0,0 +1,134 @@
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::{Client, Result, ClientError};
/// Response from the fake handler
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct FakeResponse {
#[serde(default)]
pub message: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub code: i32,
}
/// Client for the fake handler
pub struct FakeHandlerClient {
client: Client,
}
impl FakeHandlerClient {
/// Create a new fake handler client
pub fn new(socket_path: &str) -> Self {
Self {
client: Client::new(socket_path),
}
}
/// Set the connection timeout
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.client = self.client.with_timeout(timeout);
self
}
/// Connect to the server
pub fn connect(&self) -> Result<()> {
self.client.connect()
}
/// Close the connection
pub fn close(&self) -> Result<()> {
self.client.close()
}
/// Return a success message
pub fn return_success(&self, message: Option<&str>) -> Result<String> {
let mut script = "!!fake.return_success".to_string();
if let Some(msg) = message {
script.push_str(&format!(" message:'{}'", msg));
}
self.client.send_command(&script)
}
/// Return an error message
pub fn return_error(&self, message: Option<&str>) -> Result<String> {
let mut script = "!!fake.return_error".to_string();
if let Some(msg) = message {
script.push_str(&format!(" message:'{}'", msg));
}
// This will return a ClientError::ServerError with the error message
self.client.send_command(&script)
}
/// Return a JSON response
pub fn return_json(&self, message: Option<&str>, status: Option<&str>, code: Option<i32>) -> Result<FakeResponse> {
let mut script = "!!fake.return_json".to_string();
if let Some(msg) = message {
script.push_str(&format!(" message:'{}'", msg));
}
if let Some(status_val) = status {
script.push_str(&format!(" status:'{}'", status_val));
}
if let Some(code_val) = code {
script.push_str(&format!(" code:{}", code_val));
}
let response = self.client.send_command(&script)?;
// Parse the JSON response
match serde_json::from_str::<FakeResponse>(&response) {
Ok(result) => Ok(result),
Err(e) => Err(ClientError::JsonError(e)),
}
}
/// Return an invalid JSON response
pub fn return_invalid_json(&self) -> Result<FakeResponse> {
let script = "!!fake.return_invalid_json";
let response = self.client.send_command(&script)?;
// This should fail with a JSON parsing error
match serde_json::from_str::<FakeResponse>(&response) {
Ok(result) => Ok(result),
Err(e) => Err(ClientError::JsonError(e)),
}
}
/// Return an empty response
pub fn return_empty(&self) -> Result<String> {
let script = "!!fake.return_empty";
self.client.send_command(&script)
}
/// Return a large response
pub fn return_large(&self, size: Option<i32>) -> Result<String> {
let mut script = "!!fake.return_large".to_string();
if let Some(size_val) = size {
script.push_str(&format!(" size:{}", size_val));
}
self.client.send_command(&script)
}
/// Return a malformed error message
pub fn return_malformed_error(&self) -> Result<String> {
let script = "!!fake.return_malformed_error";
self.client.send_command(&script)
}
/// Get help information
pub fn help(&self) -> Result<String> {
let script = "!!fake.help";
self.client.send_command(&script)
}
}

View File

@@ -0,0 +1,242 @@
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::time::Duration;
use thiserror::Error;
use std::fmt;
use std::error::Error as StdError;
mod processmanager;
mod fakehandler;
pub use processmanager::ProcessManagerClient;
pub use fakehandler::FakeHandlerClient;
/// Standard error response from the telnet server
#[derive(Debug, Clone)]
pub struct ServerError {
pub message: String,
pub raw_response: String,
}
impl fmt::Display for ServerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl StdError for ServerError {}
/// Error type for the client
#[derive(Error, Debug)]
pub enum ClientError {
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("JSON parsing error: {0}")]
JsonError(#[from] serde_json::Error),
#[error("Connection error: {0}")]
ConnectionError(String),
#[error("Command error: {0}")]
CommandError(String),
#[error("Server error: {0}")]
ServerError(String),
}
pub type Result<T> = std::result::Result<T, ClientError>;
/// A client for connecting to a Unix socket server with improved error handling
pub struct Client {
socket_path: String,
timeout: Duration,
secret: Option<String>,
}
impl Client {
/// Create a new Unix socket client
pub fn new(socket_path: &str) -> Self {
Self {
socket_path: socket_path.to_string(),
timeout: Duration::from_secs(10),
secret: None,
}
}
/// Set the connection timeout
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Set the authentication secret
pub fn with_secret(mut self, secret: &str) -> Self {
self.secret = Some(secret.to_string());
self
}
/// Connect to the Unix socket and return the stream
fn connect_socket(&self) -> Result<UnixStream> {
println!("DEBUG: Opening new connection to {}", self.socket_path);
// Connect to the socket
let stream = UnixStream::connect(&self.socket_path)
.map_err(|e| ClientError::ConnectionError(format!("Failed to connect to socket {}: {}", self.socket_path, e)))?;
// Set read timeout
stream.set_read_timeout(Some(self.timeout))?;
stream.set_write_timeout(Some(self.timeout))?;
// Read welcome message
let mut buffer = [0; 4096];
match stream.try_clone()?.read(&mut buffer) {
Ok(n) => {
let welcome = String::from_utf8_lossy(&buffer[0..n]);
if !welcome.contains("Welcome") {
return Err(ClientError::ConnectionError("Invalid welcome message".to_string()));
}
},
Err(e) => {
return Err(ClientError::IoError(e));
}
}
// Authenticate if a secret is provided
if let Some(secret) = &self.secret {
self.authenticate_stream(&stream, secret)?;
}
Ok(stream)
}
/// Authenticate with the server using the provided stream
fn authenticate_stream(&self, stream: &UnixStream, secret: &str) -> Result<()> {
let mut stream_clone = stream.try_clone()?;
let auth_command = format!("auth {}\n\n", secret);
// Send the auth command
stream_clone.write_all(auth_command.as_bytes())
.map_err(|e| ClientError::CommandError(format!("Failed to send auth command: {}", e)))?;
stream_clone.flush()
.map_err(|e| ClientError::CommandError(format!("Failed to flush auth command: {}", e)))?;
// Add a small delay to ensure the server has time to process the command
std::thread::sleep(Duration::from_millis(100));
// Read the response
let mut buffer = [0; 4096];
let n = stream_clone.read(&mut buffer)
.map_err(|e| ClientError::CommandError(format!("Failed to read auth response: {}", e)))?;
if n == 0 {
return Err(ClientError::ConnectionError("Connection closed by server during authentication".to_string()));
}
let response = String::from_utf8_lossy(&buffer[0..n]).to_string();
// Check for authentication success
if response.contains("Authentication successful") || response.contains("authenticated") {
Ok(())
} else {
Err(ClientError::ServerError(format!("Authentication failed: {}", response)))
}
}
/// Send a command to the server and get the response
pub fn send_command(&self, command: &str) -> Result<String> {
// Connect to the socket for this command
let mut stream = self.connect_socket()?;
// Ensure command ends with double newlines to execute it
let command = if command.ends_with("\n\n") {
command.to_string()
} else if command.ends_with('\n') {
format!("{}\n", command)
} else {
format!("{}\n\n", command)
};
// Send the command
stream.write_all(command.as_bytes())
.map_err(|e| ClientError::CommandError(format!("Failed to send command: {}", e)))?;
stream.flush()
.map_err(|e| ClientError::CommandError(format!("Failed to flush command: {}", e)))?;
// Add a small delay to ensure the server has time to process the command
std::thread::sleep(Duration::from_millis(100));
// Read the response
let mut buffer = [0; 8192]; // Use a larger buffer for large responses
let n = stream.read(&mut buffer)
.map_err(|e| ClientError::CommandError(format!("Failed to read response: {}", e)))?;
if n == 0 {
return Err(ClientError::ConnectionError("Connection closed by server".to_string()));
}
let response = String::from_utf8_lossy(&buffer[0..n]).to_string();
// Remove the prompt if present
let response = response.trim_end_matches("> ").trim().to_string();
// Check for standard error format
if response.starts_with("Error:") {
return Err(ClientError::ServerError(response));
}
// Close the connection by dropping the stream
println!("DEBUG: Closing connection to {}", self.socket_path);
drop(stream);
Ok(response)
}
/// Send a command and parse the JSON response
pub fn send_command_json<T: serde::de::DeserializeOwned>(&self, command: &str) -> Result<T> {
let response = self.send_command(command)?;
// If the response is empty, return an error
if response.trim().is_empty() {
return Err(ClientError::CommandError("Empty response".to_string()));
}
// Handle "action not supported" errors specially
if response.contains("action not supported") {
return Err(ClientError::ServerError(response));
}
// Try to parse the JSON response
match serde_json::from_str::<T>(&response) {
Ok(result) => Ok(result),
Err(e) => {
// If parsing fails, check if it's an error message
if response.starts_with("Error:") || response.contains("error") || response.contains("failed") {
Err(ClientError::ServerError(response))
} else {
Err(ClientError::JsonError(e))
}
},
}
}
/// For backward compatibility
pub fn connect(&self) -> Result<()> {
// Just verify we can connect
let stream = self.connect_socket()?;
drop(stream);
Ok(())
}
/// For backward compatibility
pub fn close(&self) -> Result<()> {
// No-op since we don't maintain a persistent connection
Ok(())
}
/// Authenticate with the server - kept for backward compatibility
pub fn authenticate(&self, secret: &str) -> Result<()> {
// Create a temporary connection to authenticate
let stream = self.connect_socket()?;
self.authenticate_stream(&stream, secret)
}
}

View File

@@ -0,0 +1,164 @@
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::{Client, Result};
/// Information about a process
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ProcessInfo {
#[serde(default)]
pub name: String,
#[serde(default)]
pub command: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub pid: i32,
#[serde(default)]
pub start_time: String,
#[serde(default)]
pub uptime: String,
#[serde(default)]
pub cpu: String,
#[serde(default)]
pub memory: String,
#[serde(default)]
pub cron: Option<String>,
#[serde(default)]
pub job_id: Option<String>,
}
/// Client for the process manager
pub struct ProcessManagerClient {
client: Client,
}
impl ProcessManagerClient {
/// Create a new process manager client
pub fn new(socket_path: &str) -> Self {
Self {
client: Client::new(socket_path),
}
}
/// Set the connection timeout
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.client = self.client.with_timeout(timeout);
self
}
/// Set the authentication secret
pub fn with_secret(mut self, secret: &str) -> Self {
self.client = self.client.with_secret(secret);
self
}
/// Connect to the server
pub fn connect(&self) -> Result<()> {
self.client.connect()
}
/// Close the connection
pub fn close(&self) -> Result<()> {
self.client.close()
}
/// Start a new process
pub fn start(&self, name: &str, command: &str, log_enabled: bool, deadline: Option<i32>, cron: Option<&str>, job_id: Option<&str>) -> Result<String> {
let mut script = format!("!!process.start name:'{}' command:'{}' log:{}", name, command, log_enabled);
if let Some(deadline_val) = deadline {
script.push_str(&format!(" deadline:{}", deadline_val));
}
if let Some(cron_val) = cron {
script.push_str(&format!(" cron:'{}'", cron_val));
}
if let Some(job_id_val) = job_id {
script.push_str(&format!(" job_id:'{}'", job_id_val));
}
self.client.send_command(&script)
}
/// Stop a running process
pub fn stop(&self, name: &str) -> Result<String> {
let script = format!("!!process.stop name:'{}'", name);
self.client.send_command(&script)
}
/// Restart a process
pub fn restart(&self, name: &str) -> Result<String> {
let script = format!("!!process.restart name:'{}'", name);
self.client.send_command(&script)
}
/// Delete a process
pub fn delete(&self, name: &str) -> Result<String> {
let script = format!("!!process.delete name:'{}'", name);
self.client.send_command(&script)
}
/// List all processes
pub fn list(&self) -> Result<Vec<ProcessInfo>> {
let script = "!!process.list format:'json'";
let response = self.client.send_command(&script)?;
// Handle empty responses
if response.trim().is_empty() {
return Ok(Vec::new());
}
// Try to parse the response as JSON
match serde_json::from_str::<Vec<ProcessInfo>>(&response) {
Ok(processes) => Ok(processes),
Err(_) => {
// If parsing as a list fails, try parsing as a single ProcessInfo
match serde_json::from_str::<ProcessInfo>(&response) {
Ok(process) => Ok(vec![process]),
Err(_) => {
// If both parsing attempts fail, check if it's a "No processes found" message
if response.contains("No processes found") {
Ok(Vec::new())
} else {
// Otherwise, try to send it as JSON
self.client.send_command_json(&script)
}
}
}
}
}
}
/// Get the status of a specific process
pub fn status(&self, name: &str) -> Result<ProcessInfo> {
let script = format!("!!process.status name:'{}' format:'json'", name);
// Use the send_command_json method which handles JSON parsing with better error handling
self.client.send_command_json(&script)
}
/// Get the logs of a specific process
pub fn logs(&self, name: &str, lines: Option<i32>) -> Result<String> {
let mut script = format!("!!process.logs name:'{}'", name);
if let Some(lines_val) = lines {
script.push_str(&format!(" lines:{}", lines_val));
}
self.client.send_command(&script)
}
/// Set the logs path for the process manager
pub fn set_logs_path(&self, path: &str) -> Result<String> {
let script = format!("!!process.set_logs_path path:'{}'", path);
self.client.send_command(&script)
}
/// Get help information for the process manager
pub fn help(&self) -> Result<String> {
let script = "!!process.help";
self.client.send_command(&script)
}
}

View File

@@ -0,0 +1,137 @@
# ParamsParser
A Go package for parsing and manipulating parameters from text in a key-value format with support for multiline strings.
## Features
- Parse parameters in a natural format: `key: 'value' anotherKey: 'another value'`
- Support for multiline string values
- Support for numeric values without quotes: `port: 25`
- Support for boolean-like values: `secure: 1`
- Type conversion helpers (string, int, float, boolean)
- Default value support
- Required parameter validation with panic-on-missing options
- Simple and intuitive API
## Usage
### Basic Usage
```go
import (
"git.ourworld.tf/herocode/heroagent/pkg/paramsparser"
)
// Create a new parser
parser := paramsparser.New()
// Parse a string with parameters
inputStr := `
name: 'myapp'
host: 'localhost'
port: 25
secure: 1
reset: 1
description: '
A multiline description
for my application.
'
`
err := parser.Parse(inputStr)
if err != nil {
// Handle error
}
// Or parse a simpler one-line string
parser.ParseString("name: 'myapp' version: '1.0' active: 1")
// Set default values
parser.SetDefault("host", "localhost")
parser.SetDefault("port", "8080")
// Or set multiple defaults at once
parser.SetDefaults(map[string]string{
"debug": "false",
"timeout": "30",
})
// Get values with type conversion
name := parser.Get("name")
port := parser.GetIntDefault("port", 8080)
secure := parser.GetBool("secure")
```
### Type Conversion
```go
// String value (with default if not found)
value := parser.Get("key")
// Integer value
intValue, err := parser.GetInt("key")
// Or with default
intValue := parser.GetIntDefault("key", 42)
// Float value
floatValue, err := parser.GetFloat("key")
// Or with default
floatValue := parser.GetFloatDefault("key", 3.14)
// Boolean value (true, yes, 1, on are considered true)
boolValue := parser.GetBool("key")
// Or with default
boolValue := parser.GetBoolDefault("key", false)
```
### Required Parameters
```go
// These will panic if the parameter is missing or invalid
value := parser.MustGet("required_param")
intValue := parser.MustGetInt("required_int_param")
floatValue := parser.MustGetFloat("required_float_param")
```
### Getting All Parameters
```go
// Get all parameters (including defaults)
allParams := parser.GetAll()
for key, value := range allParams {
fmt.Printf("%s = %s\n", key, value)
}
```
## Example Input Format
The parser supports the following format:
```
name: 'myname' host: 'localhost'
port: 25
secure: 1
reset: 1
description: '
a description can be multiline
like this
'
```
Key features of the format:
- Keys are alphanumeric (plus underscore)
- String values are enclosed in single quotes
- Numeric values don't need quotes
- Boolean values can be specified as 1/0
- Multiline strings start with a single quote and continue until a closing quote is found
## Example
See the [example](./example/main.go) for a complete demonstration of how to use this package.
## Running Tests
```bash
go test -v ./pkg/paramsparser
```

View File

@@ -0,0 +1,84 @@
// Example usage of the paramsparser package
package main
import (
"fmt"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/paramsparser"
)
func main() {
// Create a new parser
parser := paramsparser.New()
// Set some default values
parser.SetDefaults(map[string]string{
"host": "localhost",
"port": "8080",
"debug": "false",
"timeout": "30",
"greeting": "Hello, World!",
})
// Parse a string in the specified format
inputStr := `
name: 'myapp'
host: 'example.com'
port: 25
secure: 1
reset: 1
description: '
This is a multiline description
for my application.
It can span multiple lines.
'
`
err := parser.Parse(inputStr)
if err != nil {
fmt.Printf("Error parsing input: %v\n", err)
return
}
// Access parameters with type conversion
name := parser.Get("name")
host := parser.Get("host")
port := parser.GetIntDefault("port", 8080)
secure := parser.GetBool("secure")
reset := parser.GetBool("reset")
description := parser.Get("description")
fmt.Println("Configuration:")
fmt.Printf(" Name: %s\n", name)
fmt.Printf(" Host: %s\n", host)
fmt.Printf(" Port: %d\n", port)
fmt.Printf(" Secure: %t\n", secure)
fmt.Printf(" Reset: %t\n", reset)
fmt.Printf(" Description: %s\n", description)
// Get all parameters
fmt.Println("\nAll parameters:")
for key, value := range parser.GetAll() {
if key == "description" {
// Truncate long values for display
if len(value) > 30 {
value = value[:30] + "..."
}
}
fmt.Printf(" %s = %s\n", key, value)
}
// Example of using MustGet for required parameters
if parser.Has("name") {
fmt.Printf("\nRequired parameter: %s\n", parser.MustGet("name"))
}
// Example of a simpler one-line parse
simpleParser := paramsparser.New()
simpleParser.ParseString("name: 'simple' version: '1.0' active: 1")
fmt.Println("\nSimple parser results:")
fmt.Printf(" Name: %s\n", simpleParser.Get("name"))
fmt.Printf(" Version: %s\n", simpleParser.Get("version"))
fmt.Printf(" Active: %t\n", simpleParser.GetBool("active"))
}

View File

@@ -0,0 +1,447 @@
// Package paramsparser provides functionality for parsing and manipulating parameters
// from text in a key-value format with support for multiline strings.
package paramsparser
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/tools"
)
// ParamsParser represents a parameter parser that can handle various parameter sources
type ParamsParser struct {
params map[string]string
defaultParams map[string]string
}
// New creates a new ParamsParser instance
func New() *ParamsParser {
return &ParamsParser{
params: make(map[string]string),
defaultParams: make(map[string]string),
}
}
// Parse parses a string containing key-value pairs in the format:
// key:value or key:'value'
// It supports multiline string values.
func (p *ParamsParser) Parse(input string) error {
// Normalize line endings
input = strings.ReplaceAll(input, "\r\n", "\n")
// Track the current state
var currentKey string
var currentValue strings.Builder
var inMultilineString bool
// Process each line
lines := strings.Split(input, "\n")
for i := 0; i < len(lines); i++ {
// Only trim space for non-multiline string processing
var line string
if !inMultilineString {
line = strings.TrimSpace(lines[i])
} else {
line = lines[i]
}
// Skip empty lines unless we're in a multiline string
if line == "" && !inMultilineString {
continue
}
// If we're in a multiline string
if inMultilineString {
// Check if this line ends the multiline string
if strings.HasSuffix(line, "'") && !strings.HasSuffix(line, "\\'") {
// Add the line without the closing quote
currentValue.WriteString(line[:len(line)-1])
p.params[currentKey] = currentValue.String()
inMultilineString = false
currentKey = ""
currentValue.Reset()
} else {
// Continue the multiline string
currentValue.WriteString(line)
currentValue.WriteString("\n")
}
continue
}
// Process the line to extract key-value pairs
var processedPos int
for processedPos < len(line) {
// Skip leading whitespace
for processedPos < len(line) && (line[processedPos] == ' ' || line[processedPos] == '\t') {
processedPos++
}
if processedPos >= len(line) {
break
}
// Find the next key by looking for a colon
keyStart := processedPos
colonPos := -1
for j := processedPos; j < len(line); j++ {
if line[j] == ':' {
colonPos = j
break
}
}
if colonPos == -1 {
// No colon found, skip this part
break
}
// Extract key and use NameFix to standardize it
rawKey := strings.TrimSpace(line[keyStart:colonPos])
key := tools.NameFix(rawKey)
if key == "" {
// Invalid key, move past the colon and continue
processedPos = colonPos + 1
continue
}
// Move position past the colon
processedPos = colonPos + 1
if processedPos >= len(line) {
// End of line reached, store empty value
p.params[key] = ""
break
}
// Skip whitespace after the colon
for processedPos < len(line) && (line[processedPos] == ' ' || line[processedPos] == '\t') {
processedPos++
}
if processedPos >= len(line) {
// End of line reached after whitespace, store empty value
p.params[key] = ""
break
}
// Check if the value is quoted
if line[processedPos] == '\'' {
// This is a quoted string
processedPos++ // Skip the opening quote
// Look for the closing quote
quoteEnd := -1
for j := processedPos; j < len(line); j++ {
// Check for escaped quote
if line[j] == '\'' && (j == 0 || line[j-1] != '\\') {
quoteEnd = j
break
}
}
if quoteEnd != -1 {
// Single-line quoted string
value := line[processedPos:quoteEnd]
// For quoted values, we preserve the original formatting
// But for single-line values, we can apply NameFix if needed
if key != "description" {
value = tools.NameFix(value)
}
p.params[key] = value
processedPos = quoteEnd + 1 // Move past the closing quote
} else {
// Start of multiline string
currentKey = key
currentValue.WriteString(line[processedPos:])
currentValue.WriteString("\n")
inMultilineString = true
break
}
} else {
// This is an unquoted value
valueStart := processedPos
valueEnd := valueStart
// Find the end of the value (space or end of line)
for valueEnd < len(line) && line[valueEnd] != ' ' && line[valueEnd] != '\t' {
valueEnd++
}
value := line[valueStart:valueEnd]
// For unquoted values, use NameFix to standardize them
// This handles the 'without' keyword and other special cases
p.params[key] = tools.NameFix(value)
processedPos = valueEnd
}
}
}
// If we're still in a multiline string at the end, that's an error
if inMultilineString {
return errors.New("unterminated multiline string")
}
return nil
}
// ParseString is a simpler version that parses a string with the format:
// key:value or key:'value'
// This version doesn't support multiline strings and is optimized for one-line inputs
func (p *ParamsParser) ParseString(input string) error {
// Trim the input
input = strings.TrimSpace(input)
// Process the input to extract key-value pairs
var processedPos int
for processedPos < len(input) {
// Skip leading whitespace
for processedPos < len(input) && (input[processedPos] == ' ' || input[processedPos] == '\t') {
processedPos++
}
if processedPos >= len(input) {
break
}
// Find the next key by looking for a colon
keyStart := processedPos
colonPos := -1
for j := processedPos; j < len(input); j++ {
if input[j] == ':' {
colonPos = j
break
}
}
if colonPos == -1 {
// No colon found, skip this part
break
}
// Extract key and use NameFix to standardize it
rawKey := strings.TrimSpace(input[keyStart:colonPos])
key := tools.NameFix(rawKey)
if key == "" {
// Invalid key, move past the colon and continue
processedPos = colonPos + 1
continue
}
// Move position past the colon
processedPos = colonPos + 1
if processedPos >= len(input) {
// End of input reached, store empty value
p.params[key] = ""
break
}
// Skip whitespace after the colon
for processedPos < len(input) && (input[processedPos] == ' ' || input[processedPos] == '\t') {
processedPos++
}
if processedPos >= len(input) {
// End of input reached after whitespace, store empty value
p.params[key] = ""
break
}
// Check if the value is quoted
if input[processedPos] == '\'' {
// This is a quoted string
processedPos++ // Skip the opening quote
// Look for the closing quote
quoteEnd := -1
for j := processedPos; j < len(input); j++ {
// Check for escaped quote
if input[j] == '\'' && (j == 0 || input[j-1] != '\\') {
quoteEnd = j
break
}
}
if quoteEnd == -1 {
return errors.New("unterminated quoted string")
}
value := input[processedPos:quoteEnd]
// For quoted values in ParseString, we can apply NameFix
// since this method doesn't handle multiline strings
if key != "description" {
value = tools.NameFix(value)
}
p.params[key] = value
processedPos = quoteEnd + 1 // Move past the closing quote
} else {
// This is an unquoted value
valueStart := processedPos
valueEnd := valueStart
// Find the end of the value (space or end of input)
for valueEnd < len(input) && input[valueEnd] != ' ' && input[valueEnd] != '\t' {
valueEnd++
}
value := input[valueStart:valueEnd]
// For unquoted values, use NameFix to standardize them
// This handles the 'without' keyword and other special cases
p.params[key] = tools.NameFix(value)
processedPos = valueEnd
}
}
return nil
}
// ParseFile parses a file containing key-value pairs
func (p *ParamsParser) ParseFile(filename string) error {
data, err := os.ReadFile(filename)
if err != nil {
return err
}
return p.Parse(string(data))
}
// SetDefault sets a default value for a parameter
func (p *ParamsParser) SetDefault(key, value string) {
p.defaultParams[key] = value
}
// SetDefaults sets multiple default values at once
func (p *ParamsParser) SetDefaults(defaults map[string]string) {
for k, v := range defaults {
p.defaultParams[k] = v
}
}
// Set explicitly sets a parameter value
func (p *ParamsParser) Set(key, value string) {
p.params[key] = value
}
// Get retrieves a parameter value, returning the default if not found
func (p *ParamsParser) Get(key string) string {
if value, exists := p.params[key]; exists {
return value
}
if defaultValue, exists := p.defaultParams[key]; exists {
return defaultValue
}
return ""
}
// GetInt retrieves a parameter as an integer
func (p *ParamsParser) GetInt(key string) (int, error) {
value := p.Get(key)
if value == "" {
return 0, errors.New("parameter not found")
}
return strconv.Atoi(value)
}
// GetIntDefault retrieves a parameter as an integer with a default value
func (p *ParamsParser) GetIntDefault(key string, defaultValue int) int {
value, err := p.GetInt(key)
if err != nil {
return defaultValue
}
return value
}
// GetBool retrieves a parameter as a boolean
func (p *ParamsParser) GetBool(key string) bool {
value := p.Get(key)
if value == "" {
return false
}
// Check for common boolean string representations
value = strings.ToLower(value)
return value == "true" || value == "yes" || value == "1" || value == "on"
}
// GetBoolDefault retrieves a parameter as a boolean with a default value
func (p *ParamsParser) GetBoolDefault(key string, defaultValue bool) bool {
if !p.Has(key) {
return defaultValue
}
return p.GetBool(key)
}
// GetFloat retrieves a parameter as a float64
func (p *ParamsParser) GetFloat(key string) (float64, error) {
value := p.Get(key)
if value == "" {
return 0, errors.New("parameter not found")
}
return strconv.ParseFloat(value, 64)
}
// GetFloatDefault retrieves a parameter as a float64 with a default value
func (p *ParamsParser) GetFloatDefault(key string, defaultValue float64) float64 {
value, err := p.GetFloat(key)
if err != nil {
return defaultValue
}
return value
}
// Has checks if a parameter exists
func (p *ParamsParser) Has(key string) bool {
_, exists := p.params[key]
return exists
}
// GetAll returns all parameters as a map
func (p *ParamsParser) GetAll() map[string]string {
result := make(map[string]string)
// First add defaults
for k, v := range p.defaultParams {
result[k] = v
}
// Then override with actual params
for k, v := range p.params {
result[k] = v
}
return result
}
// MustGet retrieves a parameter value, panicking if not found
func (p *ParamsParser) MustGet(key string) string {
value := p.Get(key)
if value == "" {
panic(fmt.Sprintf("required parameter '%s' not found", key))
}
return value
}
// MustGetInt retrieves a parameter as an integer, panicking if not found or invalid
func (p *ParamsParser) MustGetInt(key string) int {
value, err := p.GetInt(key)
if err != nil {
panic(fmt.Sprintf("required integer parameter '%s' not found or invalid", key))
}
return value
}
// MustGetFloat retrieves a parameter as a float64, panicking if not found or invalid
func (p *ParamsParser) MustGetFloat(key string) float64 {
value, err := p.GetFloat(key)
if err != nil {
panic(fmt.Sprintf("required float parameter '%s' not found or invalid", key))
}
return value
}

View File

@@ -0,0 +1,226 @@
package paramsparser
import (
"testing"
)
func TestParamsParserBasic(t *testing.T) {
input := "name:'myname' host:'localhost' port:25 secure:1 reset:1"
parser := New()
err := parser.ParseString(input)
if err != nil {
t.Fatalf("Failed to parse input: %v", err)
}
tests := []struct {
name string
key string
expected string
}{
{"string value", "name", "myname"},
{"another string value", "host", "localhost"},
{"numeric value", "port", "25"},
{"boolean-like value", "secure", "1"},
{"another boolean-like value", "reset", "1"},
{"non-existent key", "nonexistent", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parser.Get(tt.key); got != tt.expected {
t.Errorf("ParamsParser.Get(%q) = %q, want %q", tt.key, got, tt.expected)
}
})
}
}
func TestParamsParserMultiline(t *testing.T) {
input := "name:'myname' description:'\n\t\ta description can be multiline\n\n\t\tlike this\n'"
parser := New()
err := parser.Parse(input)
if err != nil {
t.Fatalf("Failed to parse input: %v", err)
}
// Check the name parameter
if got := parser.Get("name"); got != "myname" {
t.Errorf("ParamsParser.Get(\"name\") = %q, want %q", got, "myname")
}
// Check the multiline description
expectedDesc := "\n\t\ta description can be multiline\n\n\t\tlike this\n"
if got := parser.Get("description"); got != expectedDesc {
t.Errorf("ParamsParser.Get(\"description\") = %q, want %q", got, expectedDesc)
}
}
func TestParamsParserDefaults(t *testing.T) {
parser := New()
parser.SetDefault("key1", "default1")
parser.SetDefaults(map[string]string{
"key2": "default2",
"key3": "default3",
})
// Override one default
parser.Set("key2", "override")
tests := []struct {
name string
key string
expected string
}{
{"default value", "key1", "default1"},
{"overridden value", "key2", "override"},
{"another default", "key3", "default3"},
{"non-existent key", "key4", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parser.Get(tt.key); got != tt.expected {
t.Errorf("ParamsParser.Get(%q) = %q, want %q", tt.key, got, tt.expected)
}
})
}
}
func TestParamsParserTypes(t *testing.T) {
parser := New()
parser.Set("int", "123")
parser.Set("float", "3.14")
parser.Set("bool_true", "true")
parser.Set("bool_yes", "yes")
parser.Set("bool_1", "1")
parser.Set("bool_false", "false")
parser.Set("invalid_int", "not_an_int")
parser.Set("invalid_float", "not_a_float")
t.Run("GetInt", func(t *testing.T) {
if val, err := parser.GetInt("int"); err != nil || val != 123 {
t.Errorf("GetInt(\"int\") = %d, %v, want 123, nil", val, err)
}
if val, err := parser.GetInt("invalid_int"); err == nil {
t.Errorf("GetInt(\"invalid_int\") = %d, %v, want error", val, err)
}
if val, err := parser.GetInt("nonexistent"); err == nil {
t.Errorf("GetInt(\"nonexistent\") = %d, %v, want error", val, err)
}
})
t.Run("GetIntDefault", func(t *testing.T) {
if val := parser.GetIntDefault("int", 0); val != 123 {
t.Errorf("GetIntDefault(\"int\", 0) = %d, want 123", val)
}
if val := parser.GetIntDefault("invalid_int", 42); val != 42 {
t.Errorf("GetIntDefault(\"invalid_int\", 42) = %d, want 42", val)
}
if val := parser.GetIntDefault("nonexistent", 42); val != 42 {
t.Errorf("GetIntDefault(\"nonexistent\", 42) = %d, want 42", val)
}
})
t.Run("GetFloat", func(t *testing.T) {
if val, err := parser.GetFloat("float"); err != nil || val != 3.14 {
t.Errorf("GetFloat(\"float\") = %f, %v, want 3.14, nil", val, err)
}
if val, err := parser.GetFloat("invalid_float"); err == nil {
t.Errorf("GetFloat(\"invalid_float\") = %f, %v, want error", val, err)
}
if val, err := parser.GetFloat("nonexistent"); err == nil {
t.Errorf("GetFloat(\"nonexistent\") = %f, %v, want error", val, err)
}
})
t.Run("GetFloatDefault", func(t *testing.T) {
if val := parser.GetFloatDefault("float", 0.0); val != 3.14 {
t.Errorf("GetFloatDefault(\"float\", 0.0) = %f, want 3.14", val)
}
if val := parser.GetFloatDefault("invalid_float", 2.71); val != 2.71 {
t.Errorf("GetFloatDefault(\"invalid_float\", 2.71) = %f, want 2.71", val)
}
if val := parser.GetFloatDefault("nonexistent", 2.71); val != 2.71 {
t.Errorf("GetFloatDefault(\"nonexistent\", 2.71) = %f, want 2.71", val)
}
})
t.Run("GetBool", func(t *testing.T) {
if val := parser.GetBool("bool_true"); !val {
t.Errorf("GetBool(\"bool_true\") = %v, want true", val)
}
if val := parser.GetBool("bool_yes"); !val {
t.Errorf("GetBool(\"bool_yes\") = %v, want true", val)
}
if val := parser.GetBool("bool_1"); !val {
t.Errorf("GetBool(\"bool_1\") = %v, want true", val)
}
if val := parser.GetBool("bool_false"); val {
t.Errorf("GetBool(\"bool_false\") = %v, want false", val)
}
if val := parser.GetBool("nonexistent"); val {
t.Errorf("GetBool(\"nonexistent\") = %v, want false", val)
}
})
t.Run("GetBoolDefault", func(t *testing.T) {
if val := parser.GetBoolDefault("bool_true", false); !val {
t.Errorf("GetBoolDefault(\"bool_true\", false) = %v, want true", val)
}
if val := parser.GetBoolDefault("nonexistent", true); !val {
t.Errorf("GetBoolDefault(\"nonexistent\", true) = %v, want true", val)
}
})
}
func TestParamsParserGetAll(t *testing.T) {
parser := New()
parser.SetDefault("key1", "default1")
parser.SetDefault("key2", "default2")
parser.Set("key2", "override")
parser.Set("key3", "value3")
all := parser.GetAll()
expected := map[string]string{
"key1": "default1",
"key2": "override",
"key3": "value3",
}
if len(all) != len(expected) {
t.Errorf("GetAll() returned map with %d entries, want %d", len(all), len(expected))
}
for k, v := range expected {
if all[k] != v {
t.Errorf("GetAll()[%q] = %q, want %q", k, all[k], v)
}
}
}
func TestParamsParserMustGet(t *testing.T) {
parser := New()
parser.Set("key", "value")
parser.Set("int", "123")
parser.Set("float", "3.14")
defer func() {
if r := recover(); r == nil {
t.Errorf("MustGet on non-existent key did not panic")
}
}()
// These should not panic
if val := parser.MustGet("key"); val != "value" {
t.Errorf("MustGet(\"key\") = %q, want \"value\"", val)
}
if val := parser.MustGetInt("int"); val != 123 {
t.Errorf("MustGetInt(\"int\") = %d, want 123", val)
}
if val := parser.MustGetFloat("float"); val != 3.14 {
t.Errorf("MustGetFloat(\"float\") = %f, want 3.14", val)
}
// This should panic
parser.MustGet("nonexistent")
}

View File

@@ -0,0 +1,212 @@
package playbook
import (
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/paramsparser"
"git.ourworld.tf/herocode/heroagent/pkg/tools"
)
// State represents the parser state
type State int
const (
StateStart State = iota
StateCommentForActionMaybe
StateAction
StateOtherText
)
// PlayBookOptions contains options for creating a new PlayBook
type PlayBookOptions struct {
Text string
Path string
GitURL string
GitPull bool
GitBranch string
GitReset bool
Priority int
}
// AddText adds heroscript text to the playbook
func (p *PlayBook) AddText(text string, priority int) error {
// Normalize text
text = strings.ReplaceAll(text, "\t", " ")
var state State = StateStart
var action *Action
var comments []string
var paramsData []string
// Process each line
lines := strings.Split(text, "\n")
for _, line := range lines {
lineStrip := strings.TrimSpace(line)
if lineStrip == "" {
continue
}
// Handle action state
if state == StateAction {
if !strings.HasPrefix(line, " ") || lineStrip == "" || strings.HasPrefix(lineStrip, "!") {
state = StateStart
// End of action, parse params
if len(paramsData) > 0 {
params := strings.Join(paramsData, "\n")
err := action.Params.Parse(params)
if err != nil {
return err
}
// Remove ID from params if present
delete(action.Params.GetAll(), "id")
}
comments = []string{}
paramsData = []string{}
action = nil
} else {
paramsData = append(paramsData, line)
}
}
// Handle comment state
if state == StateCommentForActionMaybe {
if strings.HasPrefix(lineStrip, "//") {
comments = append(comments, strings.TrimLeft(lineStrip, "/ "))
} else {
if strings.HasPrefix(lineStrip, "!") {
state = StateStart
} else {
state = StateStart
p.OtherText += strings.Join(comments, "\n")
if !strings.HasSuffix(p.OtherText, "\n") {
p.OtherText += "\n"
}
comments = []string{}
}
}
}
// Handle start state
if state == StateStart {
if strings.HasPrefix(lineStrip, "!") && !strings.HasPrefix(lineStrip, "![") {
// Start a new action
state = StateAction
// Create new action
action = &Action{
ID: p.NrActions + 1,
Priority: priority,
Params: paramsparser.New(),
Result: paramsparser.New(),
}
p.NrActions++
// Set comments
action.Comments = strings.Join(comments, "\n")
comments = []string{}
paramsData = []string{}
// Parse action name
actionName := lineStrip
if strings.Contains(lineStrip, " ") {
actionName = strings.TrimSpace(strings.Split(lineStrip, " ")[0])
params := strings.TrimSpace(strings.Join(strings.Split(lineStrip, " ")[1:], " "))
if params != "" {
paramsData = append(paramsData, params)
}
}
// Determine action type
if strings.HasPrefix(actionName, "!!!!!") {
return ErrInvalidActionPrefix
} else if strings.HasPrefix(actionName, "!!!!") {
action.ActionType = ActionTypeWAL
} else if strings.HasPrefix(actionName, "!!!") {
action.ActionType = ActionTypeMacro
} else if strings.HasPrefix(actionName, "!!") {
action.ActionType = ActionTypeSAL
} else if strings.HasPrefix(actionName, "!") {
action.ActionType = ActionTypeDAL
}
// Remove prefix
actionName = strings.TrimLeft(actionName, "!")
// Split into actor and action name
parts := strings.Split(actionName, ".")
if len(parts) == 1 {
action.Actor = "core"
action.Name = tools.NameFix(parts[0])
} else if len(parts) == 2 {
action.Actor = tools.NameFix(parts[0])
action.Name = tools.NameFix(parts[1])
} else {
return ErrInvalidActionName
}
// Add action to playbook
p.Actions = append(p.Actions, action)
continue
} else if strings.HasPrefix(lineStrip, "//") {
state = StateCommentForActionMaybe
comments = append(comments, strings.TrimLeft(lineStrip, "/ "))
}
}
}
// Process the last action if needed
if state == StateAction && action != nil && action.ID != 0 {
if len(paramsData) > 0 {
params := strings.Join(paramsData, "\n")
err := action.Params.Parse(params)
if err != nil {
return err
}
// Remove ID from params if present
delete(action.Params.GetAll(), "id")
}
}
// Process the last comment if needed
if state == StateCommentForActionMaybe && len(comments) > 0 {
p.OtherText += strings.Join(comments, "\n")
}
return nil
}
// NewFromFile creates a new PlayBook from a file
func NewFromFile(path string, priority int) (*PlayBook, error) {
// This is a simplified version - in a real implementation, you'd read the file
// and handle different file types (md, hero, etc.)
// For now, we'll just create an empty playbook
pb := New()
// TODO: Implement file reading and parsing
return pb, nil
}
// Errors
var (
ErrInvalidActionPrefix = NewError("invalid action prefix")
ErrInvalidActionName = NewError("invalid action name")
)
// NewError creates a new error
func NewError(msg string) error {
return &PlayBookError{msg}
}
// PlayBookError represents a playbook error
type PlayBookError struct {
Msg string
}
// Error returns the error message
func (e *PlayBookError) Error() string {
return e.Msg
}

View File

@@ -0,0 +1,301 @@
package playbook
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"sort"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/paramsparser"
)
// ActionType represents the type of action
type ActionType int
const (
ActionTypeUnknown ActionType = iota
ActionTypeDAL
ActionTypeSAL
ActionTypeWAL
ActionTypeMacro
)
// Action represents a single action in a heroscript
type Action struct {
ID int
CID string
Name string
Actor string
Priority int
Params *paramsparser.ParamsParser
Result *paramsparser.ParamsParser
ActionType ActionType
Comments string
Done bool
}
// PlayBook represents a collection of actions
type PlayBook struct {
Actions []*Action
Priorities map[int][]int // key is priority, value is list of action indices
OtherText string // text outside of actions
Result string
NrActions int
Done []int
}
// NewAction creates a new action and adds it to the playbook
func (p *PlayBook) NewAction(cid, name, actor string, priority int, actionType ActionType) *Action {
p.NrActions++
action := &Action{
ID: p.NrActions,
CID: cid,
Name: name,
Actor: actor,
Priority: priority,
ActionType: actionType,
Params: paramsparser.New(),
Result: paramsparser.New(),
}
p.Actions = append(p.Actions, action)
return action
}
// New creates a new PlayBook
func New() *PlayBook {
return &PlayBook{
Actions: make([]*Action, 0),
Priorities: make(map[int][]int),
NrActions: 0,
Done: make([]int, 0),
}
}
// NewFromText creates a new PlayBook from heroscript text
func NewFromText(text string) (*PlayBook, error) {
pb := New()
err := pb.AddText(text, 10) // Default priority 10
if err != nil {
return nil, err
}
return pb, nil
}
// String returns the heroscript representation of the action
func (a *Action) String() string {
out := a.HeroScript()
if a.Result != nil && len(a.Result.GetAll()) > 0 {
out += "\n\nResult:\n"
// Indent the result
resultParams := a.Result.GetAll()
for k, v := range resultParams {
out += " " + k + ": '" + v + "'\n"
}
}
return out
}
// HeroScript returns the heroscript representation of the action
func (a *Action) HeroScript() string {
var out strings.Builder
// Add comments if any
if a.Comments != "" {
lines := strings.Split(a.Comments, "\n")
for _, line := range lines {
out.WriteString("// " + line + "\n")
}
}
// Add action type prefix
switch a.ActionType {
case ActionTypeDAL:
out.WriteString("!")
case ActionTypeSAL:
out.WriteString("!!")
case ActionTypeMacro:
out.WriteString("!!!")
default:
out.WriteString("!!") // Default to SAL
}
// Add actor and name
if a.Actor != "" {
out.WriteString(a.Actor + ".")
}
out.WriteString(a.Name + " ")
// Add ID if present
if a.ID > 0 {
out.WriteString(fmt.Sprintf("id:%d ", a.ID))
}
// Add parameters
if a.Params != nil && len(a.Params.GetAll()) > 0 {
params := a.Params.GetAll()
firstLine := true
for k, v := range params {
if firstLine {
out.WriteString(k + ":'" + v + "'\n")
firstLine = false
} else {
out.WriteString(" " + k + ":'" + v + "'\n")
}
}
}
return out.String()
}
// HashKey returns a unique hash for the action
func (a *Action) HashKey() string {
h := sha1.New()
h.Write([]byte(a.HeroScript()))
return hex.EncodeToString(h.Sum(nil))
}
// HashKey returns a unique hash for the playbook
func (p *PlayBook) HashKey() string {
h := sha1.New()
for _, action := range p.Actions {
h.Write([]byte(action.HashKey()))
}
return hex.EncodeToString(h.Sum(nil))
}
// HeroScript returns the heroscript representation of the playbook
func (p *PlayBook) HeroScript(showDone bool) string {
var out strings.Builder
actions, _ := p.ActionsSorted(false)
for _, action := range actions {
if !showDone && action.Done {
continue
}
out.WriteString(action.HeroScript() + "\n")
}
if p.OtherText != "" {
out.WriteString(p.OtherText)
}
return out.String()
}
// ActionsSorted returns the actions sorted by priority
func (p *PlayBook) ActionsSorted(prioOnly bool) ([]*Action, error) {
var result []*Action
// If no priorities are set, return all actions
if len(p.Priorities) == 0 {
return p.Actions, nil
}
// Get all priority numbers and sort them
var priorities []int
for prio := range p.Priorities {
priorities = append(priorities, prio)
}
sort.Ints(priorities)
// Add actions in priority order
for _, prio := range priorities {
if prioOnly && prio > 49 {
continue
}
actionIDs := p.Priorities[prio]
for _, id := range actionIDs {
action, err := p.GetAction(id, "", "")
if err != nil {
return nil, err
}
result = append(result, action)
}
}
return result, nil
}
// GetAction finds an action by ID, actor, or name
func (p *PlayBook) GetAction(id int, actor, name string) (*Action, error) {
actions, err := p.FindActions(id, actor, name, ActionTypeUnknown)
if err != nil {
return nil, err
}
if len(actions) == 1 {
return actions[0], nil
} else if len(actions) == 0 {
return nil, fmt.Errorf("couldn't find action with id: %d, actor: %s, name: %s", id, actor, name)
} else {
return nil, fmt.Errorf("multiple actions found with id: %d, actor: %s, name: %s", id, actor, name)
}
}
// FindActions finds actions based on criteria
func (p *PlayBook) FindActions(id int, actor, name string, actionType ActionType) ([]*Action, error) {
var result []*Action
for _, a := range p.Actions {
// If ID is specified, return only the action with that ID
if id != 0 {
if a.ID == id {
return []*Action{a}, nil
}
continue
}
// Filter by actor if specified
if actor != "" && a.Actor != actor {
continue
}
// Filter by name if specified
if name != "" && a.Name != name {
continue
}
// Filter by actionType if specified
if actionType != ActionTypeUnknown && a.ActionType != actionType {
continue
}
// If the action passes all filters, add it to the result
result = append(result, a)
}
return result, nil
}
// ActionExists checks if an action exists
func (p *PlayBook) ActionExists(id int, actor, name string) bool {
actions, err := p.FindActions(id, actor, name, ActionTypeUnknown)
if err != nil || len(actions) == 0 {
return false
}
return true
}
// String returns a string representation of the playbook
func (p *PlayBook) String() string {
return p.HeroScript(true)
}
// EmptyCheck checks if there are any actions left to execute
func (p *PlayBook) EmptyCheck() error {
var undoneActions []*Action
for _, a := range p.Actions {
if !a.Done {
undoneActions = append(undoneActions, a)
}
}
if len(undoneActions) > 0 {
return fmt.Errorf("there are actions left to execute: %d", len(undoneActions))
}
return nil
}

View File

@@ -0,0 +1,211 @@
package playbook
import (
"strings"
"testing"
)
const testText1 = `
//comment for the action
!!mailclient.configure host:localhost
name: 'myname'
port:25
secure: 1
reset:1
description:'
a description can be multiline
like this
'
`
func TestParse(t *testing.T) {
pb, err := NewFromText(testText1)
if err != nil {
t.Fatalf("Failed to parse text: %v", err)
}
if len(pb.Actions) != 1 {
t.Errorf("Expected 1 action, got %d", len(pb.Actions))
}
action := pb.Actions[0]
if action.Actor != "mailclient" {
t.Errorf("Expected actor 'mailclient', got '%s'", action.Actor)
}
if action.Name != "configure" {
t.Errorf("Expected name 'configure', got '%s'", action.Name)
}
if action.Comments != "comment for the action" {
t.Errorf("Expected comment 'comment for the action', got '%s'", action.Comments)
}
// Test params
name := action.Params.Get("name")
if name != "myname" {
t.Errorf("Expected name 'myname', got '%s'", name)
}
host := action.Params.Get("host")
if host != "localhost" {
t.Errorf("Expected host 'localhost', got '%s'", host)
}
port, err := action.Params.GetInt("port")
if err != nil || port != 25 {
t.Errorf("Expected port 25, got %d, error: %v", port, err)
}
secure := action.Params.GetBool("secure")
if !secure {
t.Errorf("Expected secure to be true, got false")
}
reset := action.Params.GetBool("reset")
if !reset {
t.Errorf("Expected reset to be true, got false")
}
// Test multiline description
desc := action.Params.Get("description")
// Just check that the description contains the expected text
if !strings.Contains(desc, "a description can be multiline") || !strings.Contains(desc, "like this") {
t.Errorf("Description doesn't contain expected content: '%s'", desc)
}
}
func TestHeroScript(t *testing.T) {
pb, err := NewFromText(testText1)
if err != nil {
t.Fatalf("Failed to parse text: %v", err)
}
// Generate heroscript
script := pb.HeroScript(true)
// Parse the generated script again
pb2, err := NewFromText(script)
if err != nil {
t.Fatalf("Failed to parse generated script: %v", err)
}
// Verify the actions are the same
if len(pb2.Actions) != len(pb.Actions) {
t.Errorf("Expected %d actions, got %d", len(pb.Actions), len(pb2.Actions))
}
// Verify the actions have the same actor and name
if pb.Actions[0].Actor != pb2.Actions[0].Actor || pb.Actions[0].Name != pb2.Actions[0].Name {
t.Errorf("Actions don't match: %s.%s vs %s.%s",
pb.Actions[0].Actor, pb.Actions[0].Name,
pb2.Actions[0].Actor, pb2.Actions[0].Name)
}
// Verify the parameters are the same
params1 := pb.Actions[0].Params.GetAll()
params2 := pb2.Actions[0].Params.GetAll()
// Check that all keys in params1 exist in params2
for k, v1 := range params1 {
v2, exists := params2[k]
if !exists {
t.Errorf("Key %s missing in generated script", k)
continue
}
// For multiline strings, just check that they contain the same content
if strings.Contains(v1, "\n") {
if !strings.Contains(v2, "description") || !strings.Contains(v2, "multiline") {
t.Errorf("Multiline value for key %s doesn't match: '%s' vs '%s'", k, v1, v2)
}
} else if v1 != v2 {
t.Errorf("Value for key %s doesn't match: '%s' vs '%s'", k, v1, v2)
}
}
}
func TestSpacedValues(t *testing.T) {
const spacedValuesText = `
!!mailclient.configure
name: 'myname'
host: 'localhost'
port: 25
secure: 1
description: 'This is a description'
`
pb, err := NewFromText(spacedValuesText)
if err != nil {
t.Fatalf("Failed to parse text with spaces between colon and quoted values: %v", err)
}
if len(pb.Actions) != 1 {
t.Errorf("Expected 1 action, got %d", len(pb.Actions))
}
action := pb.Actions[0]
if action.Actor != "mailclient" || action.Name != "configure" {
t.Errorf("Action incorrect: %s.%s", action.Actor, action.Name)
}
// Test params with spaces after colon
name := action.Params.Get("name")
if name != "myname" {
t.Errorf("Expected name 'myname', got '%s'", name)
}
host := action.Params.Get("host")
if host != "localhost" {
t.Errorf("Expected host 'localhost', got '%s'", host)
}
desc := action.Params.Get("description")
if desc != "This is a description" {
t.Errorf("Expected description 'This is a description', got '%s'", desc)
}
}
func TestMultipleActions(t *testing.T) {
const multipleActionsText = `
!!mailclient.configure
name:'myname'
host:'localhost'
!!system.update
force:1
packages:'git,curl,wget'
`
pb, err := NewFromText(multipleActionsText)
if err != nil {
t.Fatalf("Failed to parse text: %v", err)
}
if len(pb.Actions) != 2 {
t.Errorf("Expected 2 actions, got %d", len(pb.Actions))
}
// Check first action
action1 := pb.Actions[0]
if action1.Actor != "mailclient" || action1.Name != "configure" {
t.Errorf("First action incorrect: %s.%s", action1.Actor, action1.Name)
}
// Check second action
action2 := pb.Actions[1]
if action2.Actor != "system" || action2.Name != "update" {
t.Errorf("Second action incorrect: %s.%s", action2.Actor, action2.Name)
}
force := action2.Params.GetBool("force")
if !force {
t.Errorf("Expected force to be true, got false")
}
packages := action2.Params.Get("packages")
if packages != "git,curl,wget" {
t.Errorf("Expected packages 'git,curl,wget', got '%s'", packages)
}
}