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"
"tolo/executor"
"tolo/pretty"
"tolo/script"
"tolo/storage"
)
@ -239,6 +240,24 @@ 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
@ -247,13 +266,22 @@ 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 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}"))
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
}
@ -286,12 +314,33 @@ _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
@ -301,6 +350,21 @@ _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
}
@ -322,9 +386,19 @@ 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
@ -333,6 +407,7 @@ Examples:
tolo ls
tolo show server1
tolo search ssh
tolo sc cloud-start
Installation:
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
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() {
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 "Downloading from: $DOWNLOAD_URL"
@ -66,14 +72,37 @@ install_prebuilt() {
cd "$TEMP_DIR"
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
wget -O "$BINARY_NAME" "$DOWNLOAD_URL"
wget -q --show-progress -O "$ARCHIVE_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..."
@ -106,7 +135,14 @@ main() {
case "${choice:-1}" in
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
else
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)
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 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) != ""
}