fix: correct GitHub installation script URL format and archive handling
- Fix download URL to use versioned archive names (tolo-v1.0.0-linux-amd64.tar.gz) - Add proper archive extraction for .tar.gz and .zip files - Rename extracted binary from platform suffix to consistent 'tolo' name - Add download verification before proceeding with installation - Add script command support for multi-step command execution
This commit is contained in:
parent
331114c2f0
commit
0e18e90a6c
9 changed files with 850 additions and 5 deletions
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"tolo/executor"
|
"tolo/executor"
|
||||||
"tolo/pretty"
|
"tolo/pretty"
|
||||||
|
"tolo/script"
|
||||||
"tolo/storage"
|
"tolo/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -239,6 +240,24 @@ func Completion(args string) error {
|
||||||
return nil
|
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 {
|
func GenerateBashCompletion() string {
|
||||||
return `_tolo_completion() {
|
return `_tolo_completion() {
|
||||||
local cur prev opts
|
local cur prev opts
|
||||||
|
|
@ -247,13 +266,22 @@ func GenerateBashCompletion() string {
|
||||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||||
|
|
||||||
if [[ ${COMP_CWORD} -eq 1 ]]; then
|
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 help h version v"
|
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"
|
||||||
COMPREPLY=($(compgen -W "${opts}" -- "${cur}"))
|
COMPREPLY=($(compgen -W "${opts}" -- "${cur}"))
|
||||||
elif [[ ${COMP_CWORD} -eq 2 ]]; then
|
elif [[ ${COMP_CWORD} -eq 2 ]]; then
|
||||||
case "${prev}" in
|
case "${prev}" in
|
||||||
run|r|update|u|delete|del|rm|d|show|sh|info|search|se|find)
|
run|r|update|u|delete|del|rm|d|show|sh|info|search|se|find)
|
||||||
COMPREPLY=($(tolo --completion "${cur}"))
|
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
|
esac
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
@ -286,12 +314,33 @@ _tolo() {
|
||||||
'search:Search aliases'
|
'search:Search aliases'
|
||||||
'se:Search aliases (shortcut)'
|
'se:Search aliases (shortcut)'
|
||||||
'find:Search aliases (shortcut)'
|
'find:Search aliases (shortcut)'
|
||||||
|
'script:Multi-step script runner'
|
||||||
|
'sc:Multi-step script runner (shortcut)'
|
||||||
'help:Show help'
|
'help:Show help'
|
||||||
'h:Show help (shortcut)'
|
'h:Show help (shortcut)'
|
||||||
'version:Show version'
|
'version:Show version'
|
||||||
'v:Show version (shortcut)'
|
'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
|
if [[ CURRENT -eq 2 ]]; then
|
||||||
_describe 'command' commands
|
_describe 'command' commands
|
||||||
elif [[ CURRENT -eq 3 ]]; then
|
elif [[ CURRENT -eq 3 ]]; then
|
||||||
|
|
@ -301,6 +350,21 @@ _tolo() {
|
||||||
aliases=($(tolo --completion ''))
|
aliases=($(tolo --completion ''))
|
||||||
_describe 'aliases' aliases
|
_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
|
esac
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
@ -322,9 +386,19 @@ Commands:
|
||||||
list (ls, l) List all aliases
|
list (ls, l) List all aliases
|
||||||
show (sh) alias Show details of an alias
|
show (sh) alias Show details of an alias
|
||||||
search (se) query Search aliases
|
search (se) query Search aliases
|
||||||
|
script (sc) Multi-step script runner
|
||||||
help (h) Show this help message
|
help (h) Show this help message
|
||||||
version (v) Show version
|
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:
|
Examples:
|
||||||
tolo save server1:ssh user@192.168.1.10
|
tolo save server1:ssh user@192.168.1.10
|
||||||
tolo run server1
|
tolo run server1
|
||||||
|
|
@ -333,6 +407,7 @@ Examples:
|
||||||
tolo ls
|
tolo ls
|
||||||
tolo show server1
|
tolo show server1
|
||||||
tolo search ssh
|
tolo search ssh
|
||||||
|
tolo sc cloud-start
|
||||||
|
|
||||||
Installation:
|
Installation:
|
||||||
Install shell completion:
|
Install shell completion:
|
||||||
|
|
|
||||||
266
cmd/script.go
Normal file
266
cmd/script.go
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
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,3 +1,5 @@
|
||||||
module tolo
|
module tolo
|
||||||
|
|
||||||
go 1.22.0
|
go 1.22.0
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|
|
||||||
3
go.sum
Normal file
3
go.sum
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
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,7 +57,13 @@ install_from_source() {
|
||||||
|
|
||||||
install_prebuilt() {
|
install_prebuilt() {
|
||||||
PLATFORM=$(detect_os)
|
PLATFORM=$(detect_os)
|
||||||
DOWNLOAD_URL="$REPO_URL/releases/download/v$VERSION/$BINARY_NAME-$PLATFORM"
|
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"
|
||||||
|
|
||||||
echo "Installing pre-built binary for $PLATFORM..."
|
echo "Installing pre-built binary for $PLATFORM..."
|
||||||
echo "Downloading from: $DOWNLOAD_URL"
|
echo "Downloading from: $DOWNLOAD_URL"
|
||||||
|
|
@ -66,14 +72,37 @@ install_prebuilt() {
|
||||||
cd "$TEMP_DIR"
|
cd "$TEMP_DIR"
|
||||||
|
|
||||||
if command -v curl &> /dev/null; then
|
if command -v curl &> /dev/null; then
|
||||||
curl -L -o "$BINARY_NAME" "$DOWNLOAD_URL"
|
curl -fsSL -o "$ARCHIVE_NAME" "$DOWNLOAD_URL"
|
||||||
elif command -v wget &> /dev/null; then
|
elif command -v wget &> /dev/null; then
|
||||||
wget -O "$BINARY_NAME" "$DOWNLOAD_URL"
|
wget -q --show-progress -O "$ARCHIVE_NAME" "$DOWNLOAD_URL"
|
||||||
else
|
else
|
||||||
echo "Error: Neither curl nor wget is installed."
|
echo "Error: Neither curl nor wget is installed."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
chmod +x "$BINARY_NAME"
|
||||||
|
|
||||||
echo "Installing to $INSTALL_DIR..."
|
echo "Installing to $INSTALL_DIR..."
|
||||||
|
|
@ -106,7 +135,14 @@ main() {
|
||||||
|
|
||||||
case "${choice:-1}" in
|
case "${choice:-1}" in
|
||||||
1)
|
1)
|
||||||
if curl -sSf -I "$REPO_URL/releases/download/v$VERSION/$BINARY_NAME-$(detect_os)" > /dev/null 2>&1; then
|
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
|
||||||
install_prebuilt
|
install_prebuilt
|
||||||
else
|
else
|
||||||
echo "⚠ Pre-built binary not found. Building from source..."
|
echo "⚠ Pre-built binary not found. Building from source..."
|
||||||
|
|
|
||||||
5
main.go
5
main.go
|
|
@ -54,6 +54,11 @@ func main() {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
os.Exit(1)
|
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":
|
case "help", "h":
|
||||||
fmt.Println(cmd.Help())
|
fmt.Println(cmd.Help())
|
||||||
case "version", "v":
|
case "version", "v":
|
||||||
|
|
|
||||||
258
script/runner.go
Normal file
258
script/runner.go
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
62
script/script.go
Normal file
62
script/script.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
138
script/storage.go
Normal file
138
script/storage.go
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
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