diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 8cc8746c9..61e660eba 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -366,6 +366,24 @@ func WrapOpenError(err error, pathMsg, readMsg string) error { return fmt.Errorf("%s: %w", readMsg, err) } +// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors, +// using standardized messages and the given error category (e.g. "api_error", "io"). +// Path validation errors always use ErrValidation (exit code 2). +func WrapSaveErrorByCategory(err error, category string) error { + if err == nil { + return nil + } + var me *fileio.MkdirError + switch { + case errors.Is(err, fileio.ErrPathValidation): + return output.ErrValidation("unsafe output path: %s", err) + case errors.As(err, &me): + return output.Errorf(output.ExitInternal, category, "cannot create parent directory: %s", err) + default: + return output.Errorf(output.ExitInternal, category, "cannot create file: %s", err) + } +} + // ValidatePath checks that path is a valid relative input path within the // working directory by delegating to FileIO.Stat. Returns nil if the path is // valid or does not exist yet; returns an error only for illegal paths diff --git a/shortcuts/minutes/minutes_download.go b/shortcuts/minutes/minutes_download.go index 1c8a423f1..3def454f6 100644 --- a/shortcuts/minutes/minutes_download.go +++ b/shortcuts/minutes/minutes_download.go @@ -14,8 +14,7 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/vfs" - + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -79,7 +78,7 @@ var MinutesDownload = common.Shortcut{ // Batch mode: --output must be a directory, not an existing file. if !single && outputPath != "" { - if fi, err := vfs.Stat(outputPath); err == nil && !fi.IsDir() { + if fi, err := runtime.FileIO().Stat(outputPath); err == nil && !fi.IsDir() { return output.ErrValidation("--output %q is a file; batch mode expects a directory path", outputPath) } } @@ -162,7 +161,7 @@ var MinutesDownload = common.Shortcut{ fmt.Fprintf(errOut, "Downloading media: %s\n", common.MaskToken(token)) // single token: --output is a file path; batch: --output is a directory - opts := downloadOpts{overwrite: overwrite, usedNames: usedNames} + opts := downloadOpts{fio: runtime.FileIO(), overwrite: overwrite, usedNames: usedNames} if single { opts.outputPath = outputPath } else { @@ -229,8 +228,9 @@ type downloadResult struct { } type downloadOpts struct { - outputPath string // explicit output file path (single mode only) - outputDir string // output directory (batch mode) + fio fileio.FileIO // file I/O abstraction + outputPath string // explicit output file path (single mode only) + outputDir string // output directory (batch mode) overwrite bool usedNames map[string]bool // tracks used filenames to deduplicate in batch mode } @@ -275,22 +275,24 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi outputPath = filepath.Join(opts.outputDir, filename) } - safePath, err := validate.SafeOutputPath(outputPath) - if err != nil { - return nil, output.ErrValidation("unsafe output path: %s", err) - } - if err := common.EnsureWritableFile(safePath, opts.overwrite); err != nil { - return nil, err - } - if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil { - return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err) + if !opts.overwrite { + if _, statErr := opts.fio.Stat(outputPath); statErr == nil { + return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath) + } } - sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600) + result, err := opts.fio.Save(outputPath, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) if err != nil { - return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err) + return nil, common.WrapSaveErrorByCategory(err, "io") + } + resolvedPath, err := opts.fio.ResolvePath(outputPath) + if err != nil || resolvedPath == "" { + resolvedPath = outputPath } - return &downloadResult{savedPath: safePath, sizeBytes: sizeBytes}, nil + return &downloadResult{savedPath: resolvedPath, sizeBytes: result.Size()}, nil } // resolveFilenameFromResponse derives the filename from HTTP response headers. diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index 5938146ef..2c4ed72b5 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -11,8 +11,10 @@ package vc import ( + "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -22,11 +24,11 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts/common" ) @@ -262,25 +264,16 @@ func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string, base = outDir } dirName := filepath.Join(base, sanitizeDirName(title, minuteToken)) + transcriptPath := filepath.Join(dirName, "transcript.txt") + + // Overwrite check via FileIO.Stat if !runtime.Bool("overwrite") { - transcriptPath := filepath.Join(dirName, "transcript.txt") - if _, statErr := vfs.Stat(transcriptPath); statErr == nil { + if _, statErr := runtime.FileIO().Stat(transcriptPath); statErr == nil { fmt.Fprintf(errOut, "%s transcript already exists: %s (use --overwrite to replace)\n", logPrefix, transcriptPath) return transcriptPath } } - transcriptPath := filepath.Join(dirName, "transcript.txt") - safePath, err := validate.SafeOutputPath(transcriptPath) - if err != nil { - fmt.Fprintf(errOut, "%s invalid transcript path: %v\n", logPrefix, err) - return "" - } - if err := vfs.MkdirAll(filepath.Dir(safePath), 0755); err != nil { - fmt.Fprintf(errOut, "%s failed to create directory: %v\n", logPrefix, err) - return "" - } - fmt.Fprintf(errOut, "%s downloading transcript: %s\n", logPrefix, transcriptPath) apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ HttpMethod: http.MethodGet, @@ -303,8 +296,16 @@ func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string, fmt.Fprintf(errOut, "%s transcript is empty (not available for this minute)\n", logPrefix) return "" } - if err := validate.AtomicWrite(safePath, apiResp.RawBody, 0644); err != nil { - fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err) + if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(apiResp.RawBody)); err != nil { + var me *fileio.MkdirError + switch { + case errors.Is(err, fileio.ErrPathValidation): + fmt.Fprintf(errOut, "%s invalid transcript path: %v\n", logPrefix, err) + case errors.As(err, &me): + fmt.Fprintf(errOut, "%s failed to create directory: %v\n", logPrefix, err) + default: + fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err) + } return "" } return transcriptPath