From 1fd3f09a9b87664de5a646a2e9dd87d86dc78245 Mon Sep 17 00:00:00 2001 From: j3ssie Date: Sun, 25 Jan 2026 21:07:58 +0800 Subject: [PATCH] feat(cdn): enhance delete with folder support, add sync progress events, and bash() function - Enhanced cdnDelete to recursively delete folders with per-file tracking and error counts - Added optional 'json' mode parameter to cdnSyncUpload/cdnSyncDownload for output format control - Implemented real-time sync event callbacks for progress visualization with colored terminal output - Added bash() as primary function with exec_cmd() as alias for backward compatibility - Introduced SyncEvent type for action tracking (uploading/downloaded/skipped/deleted/error) - Updated function tests to support new mode parameter --- internal/functions/cdn_functions.go | 208 ++++++++++++++++++++-- internal/functions/cdn_functions_test.go | 12 +- internal/functions/constants.go | 19 +- internal/functions/goja_runtime.go | 1 + internal/functions/util_functions.go | 4 + internal/functions/util_functions_test.go | 16 ++ internal/storage/storage.go | 42 +++++ pkg/cli/function.go | 6 +- 8 files changed, 273 insertions(+), 35 deletions(-) diff --git a/internal/functions/cdn_functions.go b/internal/functions/cdn_functions.go index 1552780..5facce5 100644 --- a/internal/functions/cdn_functions.go +++ b/internal/functions/cdn_functions.go @@ -103,14 +103,68 @@ func (vf *vmFunc) cdnDelete(call goja.FunctionCall) goja.Value { return vf.vm.ToValue(false) } - ctx := context.Background() - err := storage.DeleteFile(ctx, remotePath) + client, err := storage.GetClient() if err != nil { - logger.Get().Warn("cdnDelete: delete failed", zap.String("remotePath", remotePath), zap.Error(err)) - } else { - logger.Get().Debug("cdnDelete result", zap.String("remotePath", remotePath), zap.Bool("success", true)) + logger.Get().Warn("cdnDelete: failed to get storage client", zap.Error(err)) + return vf.vm.ToValue(false) } - return vf.vm.ToValue(err == nil) + + ctx := context.Background() + deletedCount := 0 + errorCount := 0 + folderMode := strings.HasSuffix(remotePath, "/") + + if !folderMode { + exists, existsErr := client.Exists(ctx, remotePath) + if existsErr != nil { + logger.Get().Warn("cdnDelete: failed to check existence", zap.String("remotePath", remotePath), zap.Error(existsErr)) + } + if exists { + if err := client.Delete(ctx, remotePath); err != nil { + logger.Get().Warn("cdnDelete: delete failed", zap.String("remotePath", remotePath), zap.Error(err)) + errorCount++ + } else { + deletedCount++ + } + } else { + folderMode = true + } + } + + if folderMode { + prefix := remotePath + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + files, listErr := client.List(ctx, prefix) + if listErr != nil { + logger.Get().Warn("cdnDelete: list failed", zap.String("prefix", prefix), zap.Error(listErr)) + errorCount++ + } else { + for _, key := range files { + if err := client.Delete(ctx, key); err != nil { + logger.Get().Warn("cdnDelete: delete failed", zap.String("remotePath", key), zap.Error(err)) + errorCount++ + } else { + deletedCount++ + } + } + } + } + + if errorCount > 0 { + logger.Get().Warn("cdnDelete: completed with errors", + zap.String("remotePath", remotePath), + zap.Int("deleted", deletedCount), + zap.Int("errors", errorCount)) + return vf.vm.ToValue(false) + } + + logger.Get().Debug("cdnDelete result", + zap.String("remotePath", remotePath), + zap.Int("deleted", deletedCount), + zap.Bool("success", deletedCount > 0)) + return vf.vm.ToValue(deletedCount > 0) } // cdnSyncUpload synchronizes a local directory to cloud storage @@ -118,6 +172,11 @@ func (vf *vmFunc) cdnDelete(call goja.FunctionCall) goja.Value { func (vf *vmFunc) cdnSyncUpload(call goja.FunctionCall) goja.Value { localDir := call.Argument(0).String() remotePrefix := call.Argument(1).String() + mode := "" + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Argument(2)) { + mode = strings.ToLower(call.Argument(2).String()) + } + jsonOnly := mode == "json" logger.Get().Debug("Calling cdnSyncUpload", zap.String("localDir", localDir), zap.String("remotePrefix", remotePrefix)) result := map[string]interface{}{ @@ -131,22 +190,55 @@ func (vf *vmFunc) cdnSyncUpload(call goja.FunctionCall) goja.Value { if localDir == "undefined" || localDir == "" { logger.Get().Warn("cdnSyncUpload: empty local directory provided") jsonBytes, _ := json.Marshal(result) - return vf.vm.ToValue(string(jsonBytes)) + if jsonOnly { + return vf.vm.ToValue(string(jsonBytes)) + } + return vf.vm.ToValue("uploaded=0 skipped=0 deleted=0 errors=0") } client, err := storage.GetClient() if err != nil { logger.Get().Warn("cdnSyncUpload: failed to get storage client", zap.Error(err)) jsonBytes, _ := json.Marshal(result) - return vf.vm.ToValue(string(jsonBytes)) + if jsonOnly { + return vf.vm.ToValue(string(jsonBytes)) + } + return vf.vm.ToValue("uploaded=0 skipped=0 deleted=0 errors=0") } ctx := context.Background() - syncResult, err := client.SyncUpload(ctx, localDir, remotePrefix, nil) + if !jsonOnly { + prefix := terminal.InfoSymbol() + " " + terminal.HiBlue("cdn_sync_upload") + fmt.Printf("%s %s %s\n", prefix, terminal.Cyan("started"), terminal.Gray(fmt.Sprintf("%s -> %s", localDir, remotePrefix))) + } + opts := &storage.SyncOptions{} + if !jsonOnly { + infoPrefix := terminal.InfoSymbol() + " " + terminal.HiBlue("cdn_sync_upload") + warnPrefix := terminal.WarningSymbol() + " " + terminal.HiBlue("cdn_sync_upload") + errPrefix := terminal.ErrorSymbol() + " " + terminal.HiBlue("cdn_sync_upload") + opts.Event = func(event storage.SyncEvent) { + switch event.Action { + case "uploading": + fmt.Printf("%s %s %s\n", infoPrefix, terminal.Blue("uploading"), terminal.Gray(event.Path)) + case "uploaded": + fmt.Printf("%s %s %s\n", infoPrefix, terminal.Green("uploaded"), terminal.Gray(event.Path)) + case "skipped": + fmt.Printf("%s %s %s\n", warnPrefix, terminal.Yellow("skipped"), terminal.Gray(event.Path)) + case "deleted": + fmt.Printf("%s %s %s\n", warnPrefix, terminal.Magenta("deleted"), terminal.Gray(event.Path)) + case "error": + fmt.Printf("%s %s %s\n", errPrefix, terminal.Red("error"), terminal.Gray(event.Path)) + } + } + } + syncResult, err := client.SyncUpload(ctx, localDir, remotePrefix, opts) if err != nil { logger.Get().Warn("cdnSyncUpload: sync failed", zap.String("localDir", localDir), zap.Error(err)) jsonBytes, _ := json.Marshal(result) - return vf.vm.ToValue(string(jsonBytes)) + if jsonOnly { + return vf.vm.ToValue(string(jsonBytes)) + } + return vf.vm.ToValue("uploaded=0 skipped=0 deleted=0 errors=0") } result["success"] = len(syncResult.Errors) == 0 @@ -165,9 +257,30 @@ func (vf *vmFunc) cdnSyncUpload(call goja.FunctionCall) goja.Value { jsonBytes, err := json.Marshal(result) if err != nil { logger.Get().Warn("cdnSyncUpload: failed to marshal result", zap.Error(err)) - return vf.vm.ToValue("{}") + if jsonOnly { + return vf.vm.ToValue("{}") + } + return vf.vm.ToValue("uploaded=0 skipped=0 deleted=0 errors=0") } - return vf.vm.ToValue(string(jsonBytes)) + if jsonOnly { + return vf.vm.ToValue(string(jsonBytes)) + } + if !jsonOnly { + prefix := terminal.InfoSymbol() + " " + terminal.HiBlue("cdn_sync_upload") + fmt.Printf("%s %s %s\n", + prefix, + terminal.HiGreen("summary"), + terminal.Gray(fmt.Sprintf("uploaded=%d skipped=%d deleted=%d errors=%d", + len(syncResult.Uploaded), + len(syncResult.Skipped), + len(syncResult.Deleted), + len(syncResult.Errors)))) + } + return vf.vm.ToValue(fmt.Sprintf("uploaded=%d skipped=%d deleted=%d errors=%d", + len(syncResult.Uploaded), + len(syncResult.Skipped), + len(syncResult.Deleted), + len(syncResult.Errors))) } // cdnSyncDownload synchronizes cloud storage to a local directory @@ -175,6 +288,11 @@ func (vf *vmFunc) cdnSyncUpload(call goja.FunctionCall) goja.Value { func (vf *vmFunc) cdnSyncDownload(call goja.FunctionCall) goja.Value { remotePrefix := call.Argument(0).String() localDir := call.Argument(1).String() + mode := "" + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Argument(2)) { + mode = strings.ToLower(call.Argument(2).String()) + } + jsonOnly := mode == "json" logger.Get().Debug("Calling cdnSyncDownload", zap.String("remotePrefix", remotePrefix), zap.String("localDir", localDir)) result := map[string]interface{}{ @@ -188,22 +306,55 @@ func (vf *vmFunc) cdnSyncDownload(call goja.FunctionCall) goja.Value { if localDir == "undefined" || localDir == "" { logger.Get().Warn("cdnSyncDownload: empty local directory provided") jsonBytes, _ := json.Marshal(result) - return vf.vm.ToValue(string(jsonBytes)) + if jsonOnly { + return vf.vm.ToValue(string(jsonBytes)) + } + return vf.vm.ToValue("downloaded=0 skipped=0 deleted=0 errors=0") } client, err := storage.GetClient() if err != nil { logger.Get().Warn("cdnSyncDownload: failed to get storage client", zap.Error(err)) jsonBytes, _ := json.Marshal(result) - return vf.vm.ToValue(string(jsonBytes)) + if jsonOnly { + return vf.vm.ToValue(string(jsonBytes)) + } + return vf.vm.ToValue("downloaded=0 skipped=0 deleted=0 errors=0") } ctx := context.Background() - syncResult, err := client.SyncDownload(ctx, remotePrefix, localDir, nil) + if !jsonOnly { + prefix := terminal.InfoSymbol() + " " + terminal.HiBlue("cdn_sync_download") + fmt.Printf("%s %s %s\n", prefix, terminal.Cyan("started"), terminal.Gray(fmt.Sprintf("%s -> %s", remotePrefix, localDir))) + } + opts := &storage.SyncOptions{} + if !jsonOnly { + infoPrefix := terminal.InfoSymbol() + " " + terminal.HiBlue("cdn_sync_download") + warnPrefix := terminal.WarningSymbol() + " " + terminal.HiBlue("cdn_sync_download") + errPrefix := terminal.ErrorSymbol() + " " + terminal.HiBlue("cdn_sync_download") + opts.Event = func(event storage.SyncEvent) { + switch event.Action { + case "downloading": + fmt.Printf("%s %s %s\n", infoPrefix, terminal.Blue("downloading"), terminal.Gray(event.Path)) + case "downloaded": + fmt.Printf("%s %s %s\n", infoPrefix, terminal.Green("downloaded"), terminal.Gray(event.Path)) + case "skipped": + fmt.Printf("%s %s %s\n", warnPrefix, terminal.Yellow("skipped"), terminal.Gray(event.Path)) + case "deleted": + fmt.Printf("%s %s %s\n", warnPrefix, terminal.Magenta("deleted"), terminal.Gray(event.Path)) + case "error": + fmt.Printf("%s %s %s\n", errPrefix, terminal.Red("error"), terminal.Gray(event.Path)) + } + } + } + syncResult, err := client.SyncDownload(ctx, remotePrefix, localDir, opts) if err != nil { logger.Get().Warn("cdnSyncDownload: sync failed", zap.String("remotePrefix", remotePrefix), zap.Error(err)) jsonBytes, _ := json.Marshal(result) - return vf.vm.ToValue(string(jsonBytes)) + if jsonOnly { + return vf.vm.ToValue(string(jsonBytes)) + } + return vf.vm.ToValue("downloaded=0 skipped=0 deleted=0 errors=0") } result["success"] = len(syncResult.Errors) == 0 @@ -222,9 +373,30 @@ func (vf *vmFunc) cdnSyncDownload(call goja.FunctionCall) goja.Value { jsonBytes, err := json.Marshal(result) if err != nil { logger.Get().Warn("cdnSyncDownload: failed to marshal result", zap.Error(err)) - return vf.vm.ToValue("{}") + if jsonOnly { + return vf.vm.ToValue("{}") + } + return vf.vm.ToValue("downloaded=0 skipped=0 deleted=0 errors=0") } - return vf.vm.ToValue(string(jsonBytes)) + if jsonOnly { + return vf.vm.ToValue(string(jsonBytes)) + } + if !jsonOnly { + prefix := terminal.InfoSymbol() + " " + terminal.HiBlue("cdn_sync_download") + fmt.Printf("%s %s %s\n", + prefix, + terminal.HiGreen("summary"), + terminal.Gray(fmt.Sprintf("downloaded=%d skipped=%d deleted=%d errors=%d", + len(syncResult.Downloaded), + len(syncResult.Skipped), + len(syncResult.Deleted), + len(syncResult.Errors)))) + } + return vf.vm.ToValue(fmt.Sprintf("downloaded=%d skipped=%d deleted=%d errors=%d", + len(syncResult.Downloaded), + len(syncResult.Skipped), + len(syncResult.Deleted), + len(syncResult.Errors))) } // cdnGetPresignedURL generates a presigned URL for file access diff --git a/internal/functions/cdn_functions_test.go b/internal/functions/cdn_functions_test.go index 94384ce..5ec2d6d 100644 --- a/internal/functions/cdn_functions_test.go +++ b/internal/functions/cdn_functions_test.go @@ -90,7 +90,7 @@ func TestCdnDelete_EmptyPath(t *testing.T) { func TestCdnSyncUpload_EmptyLocalDir(t *testing.T) { registry := NewRegistry() result, err := registry.Execute( - `cdn_sync_upload("", "remote/prefix/")`, + `cdn_sync_upload("", "remote/prefix/", "json")`, map[string]interface{}{}, ) @@ -107,7 +107,7 @@ func TestCdnSyncUpload_EmptyLocalDir(t *testing.T) { func TestCdnSyncUpload_UndefinedArguments(t *testing.T) { registry := NewRegistry() result, err := registry.Execute( - `cdn_sync_upload()`, + `cdn_sync_upload(undefined, undefined, "json")`, map[string]interface{}{}, ) @@ -124,7 +124,7 @@ func TestCdnSyncUpload_UndefinedArguments(t *testing.T) { func TestCdnSyncDownload_EmptyLocalDir(t *testing.T) { registry := NewRegistry() result, err := registry.Execute( - `cdn_sync_download("remote/prefix/", "")`, + `cdn_sync_download("remote/prefix/", "", "json")`, map[string]interface{}{}, ) @@ -141,7 +141,7 @@ func TestCdnSyncDownload_EmptyLocalDir(t *testing.T) { func TestCdnSyncDownload_UndefinedArguments(t *testing.T) { registry := NewRegistry() result, err := registry.Execute( - `cdn_sync_download()`, + `cdn_sync_download(undefined, undefined, "json")`, map[string]interface{}{}, ) @@ -287,7 +287,7 @@ func TestCdnStat_UndefinedArgument(t *testing.T) { func TestCdnSyncUpload_ReturnStructure(t *testing.T) { registry := NewRegistry() result, err := registry.Execute( - `cdn_sync_upload("/nonexistent", "prefix/")`, + `cdn_sync_upload("/nonexistent", "prefix/", "json")`, map[string]interface{}{}, ) @@ -315,7 +315,7 @@ func TestCdnSyncUpload_ReturnStructure(t *testing.T) { func TestCdnSyncDownload_ReturnStructure(t *testing.T) { registry := NewRegistry() result, err := registry.Execute( - `cdn_sync_download("prefix/", "/nonexistent")`, + `cdn_sync_download("prefix/", "/nonexistent", "json")`, map[string]interface{}{}, ) diff --git a/internal/functions/constants.go b/internal/functions/constants.go index 6cec938..07424e2 100644 --- a/internal/functions/constants.go +++ b/internal/functions/constants.go @@ -66,13 +66,14 @@ const ( // Utility Functions - General utility operations const ( - FnLen = "len" // len(val) -> int - FnIsEmpty = "is_empty" // is_empty(val) -> bool - FnIsNotEmpty = "is_not_empty" // is_not_empty(val) -> bool - FnPrintf = "printf" // printf(message) -> void (print message to stdout) - FnCatFile = "cat_file" // cat_file(path) -> void (print file content to stdout) - FnExit = "exit" // exit(code) -> void (exit scan with code) - FnExecCmd = "exec_cmd" // exec_cmd(command) -> string (execute bash command, return stdout) + FnLen = "len" // len(val) -> int + FnIsEmpty = "is_empty" // is_empty(val) -> bool + FnIsNotEmpty = "is_not_empty" // is_not_empty(val) -> bool + FnPrintf = "printf" // printf(message) -> void (print message to stdout) + FnCatFile = "cat_file" // cat_file(path) -> void (print file content to stdout) + FnExit = "exit" // exit(code) -> void (exit scan with code) + FnExecCmd = "exec_cmd" // exec_cmd(command) -> string (alias for bash) + FnBash = "bash" FnSleep = "sleep" // sleep(seconds) -> void (pause for n seconds) FnCommandExists = "command_exists" // command_exists(command) -> bool (check if command exists in PATH) FnPickValid = "pick_valid" // pick_valid(v1, v2, ..., v10) -> any (first valid value) @@ -350,6 +351,7 @@ func AllFunctions() []string { FnCatFile, FnExit, FnExecCmd, + FnBash, FnSleep, FnCommandExists, FnPickValid, @@ -656,7 +658,8 @@ func FunctionRegistry() map[string][]FunctionInfo { {FnPrintf, "printf(message)", "Print message to stdout", "void", "printf('Scan started')"}, {FnCatFile, "cat_file(path)", "Print file content to stdout", "void", "cat_file('{{Output}}/results.txt')"}, {FnExit, "exit(code)", "Exit scan with code", "void", "exit(1)"}, - {FnExecCmd, "exec_cmd(command)", "Execute bash command and return output", "string", "exec_cmd('whoami')"}, + {FnBash, "bash(command)", "Execute bash command and return output", "string", "bash('whoami')"}, + {FnExecCmd, "exec_cmd(command)", "Alias for bash(command)", "string", "exec_cmd('whoami')"}, {FnSleep, "sleep(seconds)", "Pause for n seconds", "void", "sleep(5)"}, {FnCommandExists, "command_exists(command)", "Check if command exists in PATH", "bool", "command_exists('nmap')"}, {FnPickValid, "pick_valid(v1, v2, ..., v10)", "Return first valid value from up to 10 arguments", "any", "pick_valid('', '', 'hello', 'world')"}, diff --git a/internal/functions/goja_runtime.go b/internal/functions/goja_runtime.go index e4c3442..c1874f2 100644 --- a/internal/functions/goja_runtime.go +++ b/internal/functions/goja_runtime.go @@ -106,6 +106,7 @@ func (r *GojaRuntime) registerFunctionsOnVM(vm *goja.Runtime) { _ = vm.Set(FnCatFile, vf.catFile) _ = vm.Set(FnExit, vf.exit) _ = vm.Set(FnExecCmd, vf.execCmd) + _ = vm.Set(FnBash, vf.bash) _ = vm.Set(FnSleep, vf.sleep) _ = vm.Set(FnCommandExists, vf.commandExists) _ = vm.Set(FnPickValid, vf.pickValid) diff --git a/internal/functions/util_functions.go b/internal/functions/util_functions.go index 6de9983..e6e2014 100644 --- a/internal/functions/util_functions.go +++ b/internal/functions/util_functions.go @@ -730,6 +730,10 @@ func (vf *vmFunc) execCmd(call goja.FunctionCall) goja.Value { return vf.vm.ToValue(strings.TrimSpace(string(output))) } +func (vf *vmFunc) bash(call goja.FunctionCall) goja.Value { + return vf.execCmd(call) +} + // commandExists checks if a command is available in PATH // Usage: commandExists(command) -> bool func (vf *vmFunc) commandExists(call goja.FunctionCall) goja.Value { diff --git a/internal/functions/util_functions_test.go b/internal/functions/util_functions_test.go index cdefdaf..0909029 100644 --- a/internal/functions/util_functions_test.go +++ b/internal/functions/util_functions_test.go @@ -45,6 +45,22 @@ func TestExecCmd(t *testing.T) { }) } +func TestBash(t *testing.T) { + runtime := NewOttoRuntime() + + t.Run("simple echo command", func(t *testing.T) { + result, err := runtime.Execute(`bash("echo hello")`, nil) + require.NoError(t, err) + assert.Equal(t, "hello", result) + }) + + t.Run("empty command returns empty string", func(t *testing.T) { + result, err := runtime.Execute(`bash("")`, nil) + require.NoError(t, err) + assert.Equal(t, "", result) + }) +} + func TestCutWithDelim(t *testing.T) { runtime := NewOttoRuntime() diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 65ed2ee..badf6cd 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -64,12 +64,18 @@ type SyncResult struct { Errors []error `json:"-"` } +type SyncEvent struct { + Action string + Path string +} + // SyncOptions configures sync behavior type SyncOptions struct { Delete bool // Delete remote files not in local DryRun bool // Don't actually transfer Progress ProgressCallback // Optional progress callback Concurrency int // Parallel transfers (default: 4) + Event func(SyncEvent) } // Singleton client pattern @@ -581,12 +587,18 @@ func (c *Client) SyncUpload(ctx context.Context, localDir, remotePrefix string, // Skip if remote file is same size and modified time is not older if remoteInfo.Size == localInfo.Size() && !remoteInfo.LastModified.Before(localInfo.ModTime()) { result.Skipped = append(result.Skipped, remotePath) + if opts.Event != nil { + opts.Event(SyncEvent{Action: "skipped", Path: remotePath}) + } continue } } // Upload file if !opts.DryRun { + if opts.Event != nil { + opts.Event(SyncEvent{Action: "uploading", Path: remotePath}) + } if opts.Progress != nil { err = c.UploadWithProgress(ctx, absPath, remotePath, opts.Progress) } else { @@ -594,10 +606,16 @@ func (c *Client) SyncUpload(ctx context.Context, localDir, remotePrefix string, } if err != nil { result.Errors = append(result.Errors, fmt.Errorf("upload %s: %w", absPath, err)) + if opts.Event != nil { + opts.Event(SyncEvent{Action: "error", Path: remotePath}) + } continue } } result.Uploaded = append(result.Uploaded, remotePath) + if opts.Event != nil { + opts.Event(SyncEvent{Action: "uploaded", Path: remotePath}) + } } // Handle deletion of remote files not in local @@ -613,10 +631,16 @@ func (c *Client) SyncUpload(ctx context.Context, localDir, remotePrefix string, if !opts.DryRun { if err := c.Delete(ctx, remotePath); err != nil { result.Errors = append(result.Errors, fmt.Errorf("delete %s: %w", remotePath, err)) + if opts.Event != nil { + opts.Event(SyncEvent{Action: "error", Path: remotePath}) + } continue } } result.Deleted = append(result.Deleted, remotePath) + if opts.Event != nil { + opts.Event(SyncEvent{Action: "deleted", Path: remotePath}) + } } } } @@ -691,12 +715,18 @@ func (c *Client) SyncDownload(ctx context.Context, remotePrefix, localDir string if localInfo, err := os.Stat(localPath); err == nil { if localInfo.Size() == remoteInfo.Size && !localInfo.ModTime().Before(remoteInfo.LastModified) { result.Skipped = append(result.Skipped, remoteInfo.Key) + if opts.Event != nil { + opts.Event(SyncEvent{Action: "skipped", Path: remoteInfo.Key}) + } continue } } // Download file if !opts.DryRun { + if opts.Event != nil { + opts.Event(SyncEvent{Action: "downloading", Path: remoteInfo.Key}) + } if opts.Progress != nil { err = c.DownloadWithProgress(ctx, remoteInfo.Key, localPath, opts.Progress) } else { @@ -704,10 +734,16 @@ func (c *Client) SyncDownload(ctx context.Context, remotePrefix, localDir string } if err != nil { result.Errors = append(result.Errors, fmt.Errorf("download %s: %w", remoteInfo.Key, err)) + if opts.Event != nil { + opts.Event(SyncEvent{Action: "error", Path: remoteInfo.Key}) + } continue } } result.Downloaded = append(result.Downloaded, remoteInfo.Key) + if opts.Event != nil { + opts.Event(SyncEvent{Action: "downloaded", Path: remoteInfo.Key}) + } } // Handle deletion of local files not in remote @@ -718,10 +754,16 @@ func (c *Client) SyncDownload(ctx context.Context, remotePrefix, localDir string if !opts.DryRun { if err := os.Remove(localPath); err != nil { result.Errors = append(result.Errors, fmt.Errorf("delete local %s: %w", localPath, err)) + if opts.Event != nil { + opts.Event(SyncEvent{Action: "error", Path: localPath}) + } continue } } result.Deleted = append(result.Deleted, relPath) + if opts.Event != nil { + opts.Event(SyncEvent{Action: "deleted", Path: localPath}) + } } } } diff --git a/pkg/cli/function.go b/pkg/cli/function.go index 52cb167..0c2e016 100644 --- a/pkg/cli/function.go +++ b/pkg/cli/function.go @@ -453,12 +453,12 @@ func runFunctionList(cmd *cobra.Command, args []string) error { // to work the same as "osmedeus eval cdn_ls_tree()" func normalizeScriptExpression(expr string) string { expr = strings.TrimSpace(expr) - + // If it already has parentheses, return as-is if strings.Contains(expr, "(") { return expr } - + // Check if it's a known function name allFuncs := functions.AllFunctions() for _, fn := range allFuncs { @@ -469,7 +469,7 @@ func normalizeScriptExpression(expr string) string { return expr + "()" } } - + // Not a known function, return as-is (could be a variable or expression) return expr }