From 0e18e90a6ce3e29e10173dd6d416882d57978844 Mon Sep 17 00:00:00 2001 From: selamanapps Date: Thu, 30 Apr 2026 03:07:04 +0300 Subject: [PATCH] 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 --- cmd/commands.go | 77 +++++++++++++- cmd/script.go | 266 ++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 + go.sum | 3 + install.sh | 44 +++++++- main.go | 5 + script/runner.go | 258 ++++++++++++++++++++++++++++++++++++++++++++ script/script.go | 62 +++++++++++ script/storage.go | 138 ++++++++++++++++++++++++ 9 files changed, 850 insertions(+), 5 deletions(-) create mode 100644 cmd/script.go create mode 100644 go.sum create mode 100644 script/runner.go create mode 100644 script/script.go create mode 100644 script/storage.go diff --git a/cmd/commands.go b/cmd/commands.go index 2f05438..ebc1e4c 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -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 Run a script + tolo sc list List all scripts + tolo sc show Show script content + tolo sc steps Show script steps + tolo sc delete Delete a script + tolo sc save [-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: diff --git a/cmd/script.go b/cmd/script.go new file mode 100644 index 0000000..80456f7 --- /dev/null +++ b/cmd/script.go @@ -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 ") + 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 ") + 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 ") + 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 ") + 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 [-f ]") + 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 -f ") + 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 [arguments] + tolo sc [arguments] + tolo sc (shortcut for: tolo script run ) + +Subcommands: + run (r) Execute a script + list (ls) List all scripts + show (sh) Show script YAML content + steps Show script steps without running + delete (d) Delete a script + save [-f ] 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 to execute + register: Save stdout to a variable + if: == Run only if condition is met (also !=) + ignore_error: true Continue on failure + wait: Poll until condition is met + run: Command to poll + until: 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` +} diff --git a/go.mod b/go.mod index f65d932..308a75f 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module tolo go 1.22.0 + +require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bc0337 --- /dev/null +++ b/go.sum @@ -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= diff --git a/install.sh b/install.sh index 24547b3..486b769 100755 --- a/install.sh +++ b/install.sh @@ -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..." diff --git a/main.go b/main.go index 1459574..0a2aaca 100644 --- a/main.go +++ b/main.go @@ -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": diff --git a/script/runner.go b/script/runner.go new file mode 100644 index 0000000..7a662da --- /dev/null +++ b/script/runner.go @@ -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 +} diff --git a/script/script.go b/script/script.go new file mode 100644 index 0000000..76f6e8a --- /dev/null +++ b/script/script.go @@ -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 +} diff --git a/script/storage.go b/script/storage.go new file mode 100644 index 0000000..12ca379 --- /dev/null +++ b/script/storage.go @@ -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) != "" +}