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:
selamanapps 2026-04-30 03:07:04 +03:00
parent 331114c2f0
commit 0e18e90a6c
9 changed files with 850 additions and 5 deletions

View file

@ -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
View 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
View file

@ -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
View 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=

View file

@ -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..."

View file

@ -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
View 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
View 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
View 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) != ""
}