Compare commits
No commits in common. "main" and "v1.0.0" have entirely different histories.
10 changed files with 16 additions and 861 deletions
22
README.md
22
README.md
|
|
@ -136,20 +136,20 @@ tolo se # search (also: find)
|
|||
|
||||
```bash
|
||||
# Save SSH connection
|
||||
tolo save myserver:ssh user@192.168.1.10
|
||||
tolo save mypc:ssh amancca@192.168.0.100
|
||||
|
||||
# Use it
|
||||
tolo r myserver
|
||||
tolo r mypc
|
||||
```
|
||||
|
||||
#### Cloud Commands
|
||||
|
||||
```bash
|
||||
# Save gcloud command
|
||||
tolo save cloud-server:gcloud compute ssh --zone us-central1-a instance-1 --project my-project
|
||||
tolo save ai-server:gcloud compute ssh --zone us-central1-c ai-agent --project my-project
|
||||
|
||||
# Execute it
|
||||
tolo r cloud-server
|
||||
tolo r ai-server
|
||||
```
|
||||
|
||||
#### Docker Commands
|
||||
|
|
@ -166,7 +166,7 @@ tolo r dev
|
|||
|
||||
```bash
|
||||
# Update to change connection details
|
||||
tolo u myserver:ssh admin@192.168.1.20
|
||||
tolo u mypc:ssh root@192.168.0.100
|
||||
```
|
||||
|
||||
#### Show Alias Details
|
||||
|
|
@ -195,11 +195,11 @@ tolo find docker
|
|||
║ 📋 Saved Aliases ║
|
||||
╚════════════════════════════════════════════════════════════════╝
|
||||
|
||||
1 myserver → ssh user@192.168.1.10
|
||||
2 gcloud → gcloud compute ssh instance-1 --project my-project
|
||||
1 mypc → ssh amancca@192.168.0.100
|
||||
2 ai-server → gcloud compute ssh ai-agent --project my-journey-app-482201
|
||||
3 dev → docker-compose up -d --build
|
||||
|
||||
─────────────────────────────────────────────────────────────────
|
||||
──────────────────────────────────────────────────────────────────
|
||||
Total: 3
|
||||
```
|
||||
|
||||
|
|
@ -207,9 +207,9 @@ tolo find docker
|
|||
```
|
||||
💾 Alias saved successfully
|
||||
|
||||
Alias: myserver
|
||||
Command: ssh user@192.168.1.10
|
||||
─────────────────────────────────────────────────────────────────
|
||||
Alias: mypc
|
||||
Command: ssh amancca@192.168.0.100
|
||||
──────────────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"strings"
|
||||
"tolo/executor"
|
||||
"tolo/pretty"
|
||||
"tolo/script"
|
||||
"tolo/storage"
|
||||
)
|
||||
|
||||
|
|
@ -240,24 +239,6 @@ func Completion(args string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func ScriptCompletion(args string) error {
|
||||
scripts := script.List()
|
||||
if args == "" {
|
||||
for _, s := range scripts {
|
||||
fmt.Println(s.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
query := strings.ToLower(args)
|
||||
for _, s := range scripts {
|
||||
if strings.HasPrefix(strings.ToLower(s.Name), query) {
|
||||
fmt.Println(s.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GenerateBashCompletion() string {
|
||||
return `_tolo_completion() {
|
||||
local cur prev opts
|
||||
|
|
@ -266,22 +247,13 @@ func GenerateBashCompletion() string {
|
|||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
if [[ ${COMP_CWORD} -eq 1 ]]; then
|
||||
opts="save s run r update u delete del rm d list ls l show sh info search se find script sc help h version v"
|
||||
opts="save s run r update u delete del rm d list ls l show sh info search se find help h version v"
|
||||
COMPREPLY=($(compgen -W "${opts}" -- "${cur}"))
|
||||
elif [[ ${COMP_CWORD} -eq 2 ]]; then
|
||||
case "${prev}" in
|
||||
run|r|update|u|delete|del|rm|d|show|sh|info|search|se|find)
|
||||
COMPREPLY=($(tolo --completion "${cur}"))
|
||||
;;
|
||||
script|sc)
|
||||
COMPREPLY=($(compgen -W "run r list ls show sh steps delete del rm d save s help h" -- "${cur}"))
|
||||
;;
|
||||
esac
|
||||
elif [[ ${COMP_CWORD} -eq 3 ]]; then
|
||||
case "${prev}" in
|
||||
run|r|show|sh|steps|delete|del|rm|d)
|
||||
COMPREPLY=($(tolo sc --completion "${cur}"))
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
|
@ -314,33 +286,12 @@ _tolo() {
|
|||
'search:Search aliases'
|
||||
'se:Search aliases (shortcut)'
|
||||
'find:Search aliases (shortcut)'
|
||||
'script:Multi-step script runner'
|
||||
'sc:Multi-step script runner (shortcut)'
|
||||
'help:Show help'
|
||||
'h:Show help (shortcut)'
|
||||
'version:Show version'
|
||||
'v:Show version (shortcut)'
|
||||
)
|
||||
|
||||
local -a script_commands
|
||||
script_commands=(
|
||||
'run:Run a script'
|
||||
'r:Run a script (shortcut)'
|
||||
'list:List all scripts'
|
||||
'ls:List all scripts (shortcut)'
|
||||
'show:Show script content'
|
||||
'sh:Show script content (shortcut)'
|
||||
'steps:Show script steps'
|
||||
'delete:Delete a script'
|
||||
'del:Delete a script (shortcut)'
|
||||
'rm:Delete a script (shortcut)'
|
||||
'd:Delete a script (shortcut)'
|
||||
'save:Save a script'
|
||||
's:Save a script (shortcut)'
|
||||
'help:Show script help'
|
||||
'h:Show script help (shortcut)'
|
||||
)
|
||||
|
||||
if [[ CURRENT -eq 2 ]]; then
|
||||
_describe 'command' commands
|
||||
elif [[ CURRENT -eq 3 ]]; then
|
||||
|
|
@ -350,21 +301,6 @@ _tolo() {
|
|||
aliases=($(tolo --completion ''))
|
||||
_describe 'aliases' aliases
|
||||
;;
|
||||
script|sc)
|
||||
_describe 'script-command' script_commands
|
||||
;;
|
||||
esac
|
||||
elif [[ CURRENT -eq 4 ]]; then
|
||||
case $words[2] in
|
||||
script|sc)
|
||||
case $words[3] in
|
||||
run|r|show|sh|steps|delete|del|rm|d)
|
||||
local scripts
|
||||
scripts=($(tolo sc --completion ''))
|
||||
_describe 'scripts' scripts
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
|
@ -386,19 +322,9 @@ Commands:
|
|||
list (ls, l) List all aliases
|
||||
show (sh) alias Show details of an alias
|
||||
search (se) query Search aliases
|
||||
script (sc) Multi-step script runner
|
||||
help (h) Show this help message
|
||||
version (v) Show version
|
||||
|
||||
Script Commands:
|
||||
tolo sc <name> Run a script
|
||||
tolo sc list List all scripts
|
||||
tolo sc show <name> Show script content
|
||||
tolo sc steps <name> Show script steps
|
||||
tolo sc delete <name> Delete a script
|
||||
tolo sc save <name> [-f file] Save a script
|
||||
tolo sc help Script help & YAML format
|
||||
|
||||
Examples:
|
||||
tolo save server1:ssh user@192.168.1.10
|
||||
tolo run server1
|
||||
|
|
@ -407,7 +333,6 @@ Examples:
|
|||
tolo ls
|
||||
tolo show server1
|
||||
tolo search ssh
|
||||
tolo sc cloud-start
|
||||
|
||||
Installation:
|
||||
Install shell completion:
|
||||
|
|
|
|||
266
cmd/script.go
266
cmd/script.go
|
|
@ -1,266 +0,0 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"tolo/pretty"
|
||||
"tolo/script"
|
||||
)
|
||||
|
||||
func HandleScript(args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Println(ScriptHelp())
|
||||
return nil
|
||||
}
|
||||
|
||||
subcommand := args[0]
|
||||
rest := args[1:]
|
||||
|
||||
switch subcommand {
|
||||
case "run", "r":
|
||||
if len(rest) == 0 {
|
||||
pretty.Error("No script name specified")
|
||||
fmt.Println("Usage: tolo script run <name>")
|
||||
return fmt.Errorf("no script name specified")
|
||||
}
|
||||
return ScriptRun(strings.Join(rest, " "))
|
||||
|
||||
case "list", "ls", "l":
|
||||
return ScriptList()
|
||||
|
||||
case "show", "sh":
|
||||
if len(rest) == 0 {
|
||||
pretty.Error("No script name specified")
|
||||
fmt.Println("Usage: tolo script show <name>")
|
||||
return fmt.Errorf("no script name specified")
|
||||
}
|
||||
return ScriptShow(strings.Join(rest, " "))
|
||||
|
||||
case "steps":
|
||||
if len(rest) == 0 {
|
||||
pretty.Error("No script name specified")
|
||||
fmt.Println("Usage: tolo script steps <name>")
|
||||
return fmt.Errorf("no script name specified")
|
||||
}
|
||||
return ScriptSteps(strings.Join(rest, " "))
|
||||
|
||||
case "delete", "del", "rm", "d":
|
||||
if len(rest) == 0 {
|
||||
pretty.Error("No script name specified")
|
||||
fmt.Println("Usage: tolo script delete <name>")
|
||||
return fmt.Errorf("no script name specified")
|
||||
}
|
||||
return ScriptDelete(strings.Join(rest, " "))
|
||||
|
||||
case "save", "s":
|
||||
if len(rest) == 0 {
|
||||
pretty.Error("No script name specified")
|
||||
fmt.Println("Usage: tolo script save <name> [-f <file>]")
|
||||
return fmt.Errorf("no script name specified")
|
||||
}
|
||||
return ScriptSave(rest)
|
||||
|
||||
case "help", "h":
|
||||
fmt.Println(ScriptHelp())
|
||||
return nil
|
||||
|
||||
default:
|
||||
return ScriptRun(strings.Join(args, " "))
|
||||
}
|
||||
}
|
||||
|
||||
func ScriptRun(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
return script.RunScript(name)
|
||||
}
|
||||
|
||||
func ScriptList() error {
|
||||
scripts := script.List()
|
||||
|
||||
if len(scripts) == 0 {
|
||||
pretty.Info("No scripts found")
|
||||
pretty.Newline()
|
||||
pretty.Dim("Create scripts in ~/.tolo/scripts/ as YAML files")
|
||||
pretty.Dim("Or use: tolo script save <name> -f <file>")
|
||||
return nil
|
||||
}
|
||||
|
||||
pretty.Header("Saved Scripts")
|
||||
|
||||
maxNameLen := 0
|
||||
for _, s := range scripts {
|
||||
if len(s.Name) > maxNameLen {
|
||||
maxNameLen = len(s.Name)
|
||||
}
|
||||
}
|
||||
|
||||
for i, s := range scripts {
|
||||
desc := ""
|
||||
if s.Description != "" {
|
||||
desc = s.Description
|
||||
} else {
|
||||
desc = fmt.Sprintf("%d steps", s.StepCount)
|
||||
}
|
||||
|
||||
fmt.Printf(" %s%d%s %s%-*s%s %s→%s %s%s%s\n",
|
||||
"\033[2m", i+1, "\033[0m",
|
||||
"\033[1m", maxNameLen, s.Name, "\033[0m",
|
||||
"\033[36m", "\033[0m",
|
||||
"\033[32m", desc, "\033[0m")
|
||||
}
|
||||
|
||||
pretty.Separator()
|
||||
fmt.Printf(" Total: ")
|
||||
pretty.Count(len(scripts))
|
||||
return nil
|
||||
}
|
||||
|
||||
func ScriptShow(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
data, err := script.LoadRaw(name)
|
||||
if err != nil {
|
||||
pretty.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
pretty.Header(fmt.Sprintf("Script: %s", name))
|
||||
fmt.Printf(" %s%s%s\n", "\033[32m", string(data), "\033[0m")
|
||||
pretty.Separator()
|
||||
return nil
|
||||
}
|
||||
|
||||
func ScriptSteps(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
return script.RunScriptSteps(name)
|
||||
}
|
||||
|
||||
func ScriptDelete(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
if err := script.Delete(name); err != nil {
|
||||
pretty.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
pretty.Deleted(fmt.Sprintf("Script '%s' deleted", name))
|
||||
return nil
|
||||
}
|
||||
|
||||
func ScriptSave(args []string) error {
|
||||
var name string
|
||||
var filePath string
|
||||
|
||||
name = args[0]
|
||||
|
||||
for i := 1; i < len(args); i++ {
|
||||
if args[i] == "-f" || args[i] == "--file" {
|
||||
if i+1 < len(args) {
|
||||
filePath = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var content []byte
|
||||
var err error
|
||||
|
||||
if filePath != "" {
|
||||
content, err = os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
pretty.Error(fmt.Sprintf("Failed to read file: %v", err))
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
content, err = io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
pretty.Error(fmt.Sprintf("Failed to read stdin: %v", err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(content) == 0 {
|
||||
pretty.Error("No script content provided")
|
||||
return fmt.Errorf("empty script content")
|
||||
}
|
||||
|
||||
if err := script.Save(name, content); err != nil {
|
||||
pretty.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
s, _ := script.Parse(content)
|
||||
pretty.Saved("Script saved successfully")
|
||||
pretty.Newline()
|
||||
pretty.Label("Name: ")
|
||||
pretty.Alias(s.Name)
|
||||
pretty.Newline()
|
||||
if s.Description != "" {
|
||||
pretty.Label("Description: ")
|
||||
pretty.Command(s.Description)
|
||||
}
|
||||
pretty.Label("Steps: ")
|
||||
pretty.Count(len(s.Steps))
|
||||
return nil
|
||||
}
|
||||
|
||||
func ScriptHelp() string {
|
||||
return `tolo script - Multi-step script runner
|
||||
|
||||
Usage:
|
||||
tolo script <subcommand> [arguments]
|
||||
tolo sc <subcommand> [arguments]
|
||||
tolo sc <name> (shortcut for: tolo script run <name>)
|
||||
|
||||
Subcommands:
|
||||
run <name> (r) Execute a script
|
||||
list (ls) List all scripts
|
||||
show <name> (sh) Show script YAML content
|
||||
steps <name> Show script steps without running
|
||||
delete <name> (d) Delete a script
|
||||
save <name> [-f <file>] Save a script from file or stdin
|
||||
help (h) Show this help
|
||||
|
||||
Script YAML Format:
|
||||
name: my-script
|
||||
description: What this script does
|
||||
steps:
|
||||
- name: Check status
|
||||
run: gcloud compute instances describe my-vm --format="value(status)"
|
||||
register: status
|
||||
|
||||
- name: Start if stopped
|
||||
if: "{{.status}} != RUNNING"
|
||||
run: gcloud compute instances start my-vm
|
||||
|
||||
- name: Wait until ready
|
||||
wait:
|
||||
run: gcloud compute instances describe my-vm --format="value(status)"
|
||||
until: RUNNING
|
||||
timeout: 5m
|
||||
interval: 10s
|
||||
|
||||
- name: Connect
|
||||
run: tolo r myserver
|
||||
|
||||
Step Options:
|
||||
run: <command> Command to execute
|
||||
register: <var> Save stdout to a variable
|
||||
if: <var> == <value> Run only if condition is met (also !=)
|
||||
ignore_error: true Continue on failure
|
||||
wait: Poll until condition is met
|
||||
run: <command> Command to poll
|
||||
until: <value> Expected output (trimmed)
|
||||
timeout: 5m Max wait time (default: 5m)
|
||||
interval: 10s Time between polls (default: 5s)
|
||||
|
||||
Examples:
|
||||
tolo sc cloud-start Run a script
|
||||
tolo script list List all scripts
|
||||
tolo script save deploy -f deploy.yaml Save from file
|
||||
cat script.yaml | tolo script save my Save from stdin
|
||||
tolo script show cloud-start View script
|
||||
tolo script delete old-script Delete a script`
|
||||
}
|
||||
2
go.mod
2
go.mod
|
|
@ -1,5 +1,3 @@
|
|||
module tolo
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
|
|
|||
3
go.sum
3
go.sum
|
|
@ -1,3 +0,0 @@
|
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
44
install.sh
44
install.sh
|
|
@ -57,13 +57,7 @@ install_from_source() {
|
|||
|
||||
install_prebuilt() {
|
||||
PLATFORM=$(detect_os)
|
||||
ARCHIVE_NAME="$BINARY_NAME-v$VERSION-$PLATFORM.tar.gz"
|
||||
|
||||
if [[ "$PLATFORM" == windows-* ]]; then
|
||||
ARCHIVE_NAME="$BINARY_NAME-v$VERSION-$PLATFORM.zip"
|
||||
fi
|
||||
|
||||
DOWNLOAD_URL="$REPO_URL/releases/download/v$VERSION/$ARCHIVE_NAME"
|
||||
DOWNLOAD_URL="$REPO_URL/releases/download/v$VERSION/$BINARY_NAME-$PLATFORM"
|
||||
|
||||
echo "Installing pre-built binary for $PLATFORM..."
|
||||
echo "Downloading from: $DOWNLOAD_URL"
|
||||
|
|
@ -72,37 +66,14 @@ install_prebuilt() {
|
|||
cd "$TEMP_DIR"
|
||||
|
||||
if command -v curl &> /dev/null; then
|
||||
curl -fsSL -o "$ARCHIVE_NAME" "$DOWNLOAD_URL"
|
||||
curl -L -o "$BINARY_NAME" "$DOWNLOAD_URL"
|
||||
elif command -v wget &> /dev/null; then
|
||||
wget -q --show-progress -O "$ARCHIVE_NAME" "$DOWNLOAD_URL"
|
||||
wget -O "$BINARY_NAME" "$DOWNLOAD_URL"
|
||||
else
|
||||
echo "Error: Neither curl nor wget is installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$ARCHIVE_NAME" ]; then
|
||||
echo "Error: Download failed. File not found: $ARCHIVE_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Extracting..."
|
||||
if [[ "$ARCHIVE_NAME" == *.tar.gz ]]; then
|
||||
tar -xzf "$ARCHIVE_NAME"
|
||||
rm "$ARCHIVE_NAME"
|
||||
EXTRACTED_BINARY=$(ls -1 "$BINARY_NAME"-* 2>/dev/null | head -1)
|
||||
if [ -n "$EXTRACTED_BINARY" ] && [ -f "$EXTRACTED_BINARY" ]; then
|
||||
mv "$EXTRACTED_BINARY" "$BINARY_NAME"
|
||||
fi
|
||||
elif [[ "$ARCHIVE_NAME" == *.zip ]]; then
|
||||
unzip -q "$ARCHIVE_NAME"
|
||||
rm "$ARCHIVE_NAME"
|
||||
EXTRACTED_BINARY=$(ls -1 "$BINARY_NAME"*.exe 2>/dev/null | head -1)
|
||||
if [ -n "$EXTRACTED_BINARY" ] && [ -f "$EXTRACTED_BINARY" ]; then
|
||||
mv "$EXTRACTED_BINARY" "$BINARY_NAME.exe"
|
||||
BINARY_NAME="$BINARY_NAME.exe"
|
||||
fi
|
||||
fi
|
||||
|
||||
chmod +x "$BINARY_NAME"
|
||||
|
||||
echo "Installing to $INSTALL_DIR..."
|
||||
|
|
@ -135,14 +106,7 @@ main() {
|
|||
|
||||
case "${choice:-1}" in
|
||||
1)
|
||||
PLATFORM=$(detect_os)
|
||||
if [[ "$PLATFORM" == windows-* ]]; then
|
||||
ARCHIVE_NAME="$BINARY_NAME-v$VERSION-$PLATFORM.zip"
|
||||
else
|
||||
ARCHIVE_NAME="$BINARY_NAME-v$VERSION-$PLATFORM.tar.gz"
|
||||
fi
|
||||
|
||||
if curl -sSf -I "$REPO_URL/releases/download/v$VERSION/$ARCHIVE_NAME" > /dev/null 2>&1; then
|
||||
if curl -sSf -I "$REPO_URL/releases/download/v$VERSION/$BINARY_NAME-$(detect_os)" > /dev/null 2>&1; then
|
||||
install_prebuilt
|
||||
else
|
||||
echo "⚠ Pre-built binary not found. Building from source..."
|
||||
|
|
|
|||
5
main.go
5
main.go
|
|
@ -54,11 +54,6 @@ func main() {
|
|||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "script", "sc":
|
||||
if err := cmd.HandleScript(os.Args[2:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "help", "h":
|
||||
fmt.Println(cmd.Help())
|
||||
case "version", "v":
|
||||
|
|
|
|||
258
script/runner.go
258
script/runner.go
|
|
@ -1,258 +0,0 @@
|
|||
package script
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
"tolo/pretty"
|
||||
)
|
||||
|
||||
type Runner struct {
|
||||
vars map[string]string
|
||||
}
|
||||
|
||||
func NewRunner() *Runner {
|
||||
return &Runner{
|
||||
vars: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) Run(s *Script) error {
|
||||
total := len(s.Steps)
|
||||
pretty.Header(fmt.Sprintf("Running Script: %s", s.Name))
|
||||
if s.Description != "" {
|
||||
pretty.Dim(" " + s.Description)
|
||||
pretty.Newline()
|
||||
}
|
||||
|
||||
for i, step := range s.Steps {
|
||||
num := i + 1
|
||||
pretty.Label(fmt.Sprintf(" Step %d/%d: ", num, total))
|
||||
pretty.Alias(step.Name)
|
||||
pretty.Newline()
|
||||
|
||||
if step.Condition != "" {
|
||||
ok, err := evaluateCondition(step.Condition, r.vars)
|
||||
if err != nil {
|
||||
return fmt.Errorf("step %d (%s): condition error: %w", num, step.Name, err)
|
||||
}
|
||||
if !ok {
|
||||
pretty.Dim(" Skipped (condition not met)")
|
||||
if num < total {
|
||||
pretty.Newline()
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var stepErr error
|
||||
if step.Wait != nil {
|
||||
stepErr = r.runWait(step.Wait, num, total)
|
||||
} else if step.Run != "" {
|
||||
stepErr = r.runCommand(step.Run, step.Register)
|
||||
}
|
||||
|
||||
if stepErr != nil {
|
||||
if !step.IgnoreError {
|
||||
return fmt.Errorf("step %d (%s): %w", num, step.Name, stepErr)
|
||||
}
|
||||
pretty.Dim(fmt.Sprintf(" Warning: %v (ignored)", stepErr))
|
||||
}
|
||||
|
||||
if num < total {
|
||||
pretty.Newline()
|
||||
}
|
||||
}
|
||||
|
||||
pretty.Newline()
|
||||
pretty.Saved("Script completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) runCommand(command string, register string) error {
|
||||
cmd := substituteVars(command, r.vars)
|
||||
pretty.Dim(fmt.Sprintf(" Running: %s", cmd))
|
||||
|
||||
output, err := execute(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output = strings.TrimSpace(output)
|
||||
if output != "" {
|
||||
fmt.Printf(" %sOutput:%s %s\n", "\033[2m", "\033[0m", output)
|
||||
}
|
||||
|
||||
if register != "" {
|
||||
r.vars[register] = output
|
||||
pretty.Dim(fmt.Sprintf(" Saved: %s = %s", register, output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) runWait(w *Wait, stepNum, total int) error {
|
||||
timeout := 5 * time.Minute
|
||||
interval := 5 * time.Second
|
||||
|
||||
if w.Timeout != "" {
|
||||
d, err := time.ParseDuration(w.Timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timeout '%s': %w", w.Timeout, err)
|
||||
}
|
||||
timeout = d
|
||||
}
|
||||
if w.Interval != "" {
|
||||
d, err := time.ParseDuration(w.Interval)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval '%s': %w", w.Interval, err)
|
||||
}
|
||||
interval = d
|
||||
}
|
||||
|
||||
cmd := substituteVars(w.Run, r.vars)
|
||||
until := substituteVars(w.Until, r.vars)
|
||||
|
||||
pretty.Dim(fmt.Sprintf(" Waiting (timeout: %s, interval: %s)...", timeout, interval))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
for {
|
||||
output, err := execute(cmd)
|
||||
if err != nil {
|
||||
output = ""
|
||||
}
|
||||
output = strings.TrimSpace(output)
|
||||
|
||||
if output == until {
|
||||
elapsed := time.Since(start).Truncate(time.Second)
|
||||
pretty.Dim(fmt.Sprintf(" Condition met after %s: %s", elapsed, output))
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("timeout after %s (last output: '%s', expected: '%s')",
|
||||
timeout, output, until)
|
||||
case <-time.After(interval):
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func execute(command string) (string, error) {
|
||||
parts := parseCommand(command)
|
||||
if len(parts) == 0 {
|
||||
return "", fmt.Errorf("empty command")
|
||||
}
|
||||
|
||||
cmd := exec.Command(parts[0], parts[1:]...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func evaluateCondition(cond string, vars map[string]string) (bool, error) {
|
||||
cond = strings.TrimSpace(cond)
|
||||
|
||||
if strings.Contains(cond, "!=") {
|
||||
parts := strings.SplitN(cond, "!=", 2)
|
||||
left := strings.TrimSpace(substituteVars(parts[0], vars))
|
||||
right := strings.TrimSpace(parts[1])
|
||||
return left != right, nil
|
||||
}
|
||||
|
||||
if strings.Contains(cond, "==") {
|
||||
parts := strings.SplitN(cond, "==", 2)
|
||||
left := strings.TrimSpace(substituteVars(parts[0], vars))
|
||||
right := strings.TrimSpace(parts[1])
|
||||
return left == right, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("invalid condition '%s' (use == or !=)", cond)
|
||||
}
|
||||
|
||||
func substituteVars(s string, vars map[string]string) string {
|
||||
for k, v := range vars {
|
||||
s = strings.ReplaceAll(s, "{{."+k+"}}", v)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseCommand(command string) []string {
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
var inSingleQuote, inDoubleQuote bool
|
||||
|
||||
for _, r := range command {
|
||||
switch {
|
||||
case r == '\'' && !inDoubleQuote:
|
||||
inSingleQuote = !inSingleQuote
|
||||
case r == '"' && !inSingleQuote:
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
case r == ' ' && !inSingleQuote && !inDoubleQuote:
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
default:
|
||||
current.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
func RunScript(name string) error {
|
||||
s, err := Load(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runner := NewRunner()
|
||||
return runner.Run(s)
|
||||
}
|
||||
|
||||
func RunScriptSteps(name string) error {
|
||||
s, err := Load(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pretty.Header(fmt.Sprintf("Script: %s", s.Name))
|
||||
if s.Description != "" {
|
||||
pretty.Dim(" " + s.Description)
|
||||
pretty.Newline()
|
||||
}
|
||||
|
||||
for i, step := range s.Steps {
|
||||
fmt.Printf(" %s%d.%s %s", "\033[2m", i+1, "\033[0m", step.Name)
|
||||
if step.Condition != "" {
|
||||
fmt.Printf(" %s(if: %s)%s", "\033[33m", step.Condition, "\033[0m")
|
||||
}
|
||||
if step.Wait != nil {
|
||||
fmt.Printf(" %s[wait]%s", "\033[36m", "\033[0m")
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
pretty.Separator()
|
||||
fmt.Printf(" Total: ")
|
||||
pretty.Count(len(s.Steps))
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
package script
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Script struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Steps []Step `yaml:"steps"`
|
||||
}
|
||||
|
||||
type Step struct {
|
||||
Name string `yaml:"name"`
|
||||
Run string `yaml:"run"`
|
||||
Register string `yaml:"register"`
|
||||
Condition string `yaml:"if"`
|
||||
IgnoreError bool `yaml:"ignore_error"`
|
||||
Wait *Wait `yaml:"wait"`
|
||||
}
|
||||
|
||||
type Wait struct {
|
||||
Run string `yaml:"run"`
|
||||
Until string `yaml:"until"`
|
||||
Timeout string `yaml:"timeout"`
|
||||
Interval string `yaml:"interval"`
|
||||
}
|
||||
|
||||
func Parse(data []byte) (*Script, error) {
|
||||
var s Script
|
||||
if err := yaml.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("invalid script YAML: %w", err)
|
||||
}
|
||||
|
||||
if s.Name == "" {
|
||||
return nil, fmt.Errorf("script must have a 'name' field")
|
||||
}
|
||||
if len(s.Steps) == 0 {
|
||||
return nil, fmt.Errorf("script must have at least one step")
|
||||
}
|
||||
|
||||
for i, step := range s.Steps {
|
||||
if step.Name == "" {
|
||||
return nil, fmt.Errorf("step %d must have a 'name' field", i+1)
|
||||
}
|
||||
if step.Run == "" && step.Wait == nil {
|
||||
return nil, fmt.Errorf("step %d (%s) must have either 'run' or 'wait'", i+1, step.Name)
|
||||
}
|
||||
if step.Wait != nil {
|
||||
if step.Wait.Run == "" {
|
||||
return nil, fmt.Errorf("step %d (%s): wait must have a 'run' field", i+1, step.Name)
|
||||
}
|
||||
if step.Wait.Until == "" {
|
||||
return nil, fmt.Errorf("step %d (%s): wait must have an 'until' field", i+1, step.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &s, nil
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
package script
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const scriptsSubdir = "scripts"
|
||||
|
||||
type ScriptInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
StepCount int
|
||||
ModifiedAt time.Time
|
||||
}
|
||||
|
||||
func scriptsDir() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return filepath.Join(homeDir, ".tolo", scriptsSubdir)
|
||||
}
|
||||
|
||||
func findFile(name string) string {
|
||||
dir := scriptsDir()
|
||||
for _, ext := range []string{".yaml", ".yml"} {
|
||||
p := filepath.Join(dir, name+ext)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Load(name string) (*Script, error) {
|
||||
p := findFile(name)
|
||||
if p == "" {
|
||||
return nil, fmt.Errorf("script '%s' not found", name)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read script: %w", err)
|
||||
}
|
||||
|
||||
return Parse(data)
|
||||
}
|
||||
|
||||
func LoadRaw(name string) ([]byte, error) {
|
||||
p := findFile(name)
|
||||
if p == "" {
|
||||
return nil, fmt.Errorf("script '%s' not found", name)
|
||||
}
|
||||
return os.ReadFile(p)
|
||||
}
|
||||
|
||||
func Save(name string, content []byte) error {
|
||||
dir := scriptsDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s, err := Parse(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filename := filepath.Join(dir, s.Name+".yaml")
|
||||
return os.WriteFile(filename, content, 0644)
|
||||
}
|
||||
|
||||
func Delete(name string) error {
|
||||
p := findFile(name)
|
||||
if p == "" {
|
||||
return fmt.Errorf("script '%s' not found", name)
|
||||
}
|
||||
return os.Remove(p)
|
||||
}
|
||||
|
||||
func List() []ScriptInfo {
|
||||
dir := scriptsDir()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var scripts []ScriptInfo
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(entry.Name()))
|
||||
if ext != ".yaml" && ext != ".yml" {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
|
||||
p := filepath.Join(dir, entry.Name())
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var s Script
|
||||
if err := yaml.Unmarshal(data, &s); err != nil {
|
||||
scripts = append(scripts, ScriptInfo{
|
||||
Name: name,
|
||||
ModifiedAt: info.ModTime(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
scripts = append(scripts, ScriptInfo{
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
StepCount: len(s.Steps),
|
||||
ModifiedAt: info.ModTime(),
|
||||
})
|
||||
}
|
||||
|
||||
return scripts
|
||||
}
|
||||
|
||||
func Exists(name string) bool {
|
||||
return findFile(name) != ""
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue