From 71313c37b776848ef930a78b017a3832c673bffa Mon Sep 17 00:00:00 2001 From: tuxedomm <273098272+tuxedomm@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:48:43 +0800 Subject: [PATCH 1/3] refactor: migrate vc/minutes shortcuts to FileIO - vc_notes: replace vfs.Stat + validate.SafeOutputPath + validate.AtomicWrite with FileIO.Stat/Save for transcript download - minutes_download: replace validate.SafeOutputPath + validate.AtomicWriteFromReader with FileIO.Save, use FileIO.Stat for overwrite checks - Use WrapSaveError to preserve original error messages Change-Id: I7cdeddf933b1ca76266d499ec2678eb8ce6875f3 --- shortcuts/common/runner.go | 18 +++++++++++++ shortcuts/minutes/minutes_download.go | 38 ++++++++++++++------------- shortcuts/vc/vc_notes.go | 22 +++++----------- 3 files changed, 45 insertions(+), 33 deletions(-) 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..83244a55b 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, "api_error") + } + resolvedPath, _ := opts.fio.ResolvePath(outputPath) + if 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..684ed9370 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -11,6 +11,7 @@ package vc import ( + "bytes" "context" "encoding/json" "fmt" @@ -22,11 +23,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 +263,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,7 +295,7 @@ 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 { + if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(apiResp.RawBody)); err != nil { fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err) return "" } From d9833022ca50cd818d1b959b9bac39c502f004d7 Mon Sep 17 00:00:00 2001 From: tuxedomm <273098272+tuxedomm@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:35:56 +0800 Subject: [PATCH 2/3] fix: preserve original error messages in vc_notes transcript download FileIO.Save errors now distinguish path validation, mkdir, and write failures to match the original stderr messages exactly. Change-Id: Ic578a15fc72f68d893ee9717fd86a4b8c72192d0 --- shortcuts/vc/vc_notes.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index 684ed9370..2c4ed72b5 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -14,6 +14,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -296,7 +297,15 @@ func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string, return "" } if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(apiResp.RawBody)); err != nil { - fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err) + 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 From 6d36a82ec17271512f2b659fe55d563bd648f38a Mon Sep 17 00:00:00 2001 From: tuxedomm <273098272+tuxedomm@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:22:35 +0800 Subject: [PATCH 3/3] fix: correct error category and handle ResolvePath error in minutes download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WrapSaveErrorByCategory: "api_error" → "io" for local file I/O failures - ResolvePath: check returned error instead of discarding it Change-Id: I49e5a667096a81f412816c4ef88b68db8ef4a45f --- shortcuts/minutes/minutes_download.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shortcuts/minutes/minutes_download.go b/shortcuts/minutes/minutes_download.go index 83244a55b..3def454f6 100644 --- a/shortcuts/minutes/minutes_download.go +++ b/shortcuts/minutes/minutes_download.go @@ -286,10 +286,10 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi ContentLength: resp.ContentLength, }, resp.Body) if err != nil { - return nil, common.WrapSaveErrorByCategory(err, "api_error") + return nil, common.WrapSaveErrorByCategory(err, "io") } - resolvedPath, _ := opts.fio.ResolvePath(outputPath) - if resolvedPath == "" { + resolvedPath, err := opts.fio.ResolvePath(outputPath) + if err != nil || resolvedPath == "" { resolvedPath = outputPath } return &downloadResult{savedPath: resolvedPath, sizeBytes: result.Size()}, nil