@@ -17,6 +17,7 @@ import (
1717 "strings"
1818 "time"
1919
20+ "github.com/larksuite/cli/errs"
2021 "github.com/larksuite/cli/extension/fileio"
2122 "github.com/larksuite/cli/internal/output"
2223 "github.com/larksuite/cli/internal/validate"
@@ -54,21 +55,21 @@ var MinutesDownload = common.Shortcut{
5455 Validate : func (ctx context.Context , runtime * common.RuntimeContext ) error {
5556 tokens := common .SplitCSV (runtime .Str ("minute-tokens" ))
5657 if len (tokens ) == 0 {
57- return output . ErrValidation ( "--minute-tokens is required" )
58+ return errs . NewValidationError ( errs . SubtypeInvalidArgument , "--minute-tokens is required" ). WithParam ( "--minute-tokens " )
5859 }
5960 if len (tokens ) > maxBatchSize {
60- return output . ErrValidation ( "--minute-tokens: too many tokens (%d), maximum is %d" , len (tokens ), maxBatchSize )
61+ return errs . NewValidationError ( errs . SubtypeInvalidArgument , "--minute-tokens: too many tokens (%d), maximum is %d" , len (tokens ), maxBatchSize ). WithParam ( "--minute-tokens" )
6162 }
6263 for _ , token := range tokens {
6364 if ! validMinuteToken .MatchString (token ) {
64- return output . ErrValidation ( "invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)" , token )
65+ return errs . NewValidationError ( errs . SubtypeInvalidArgument , "invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)" , token ). WithParam ( "--minute-tokens" )
6566 }
6667 }
6768 // Cheap checks first, then path-safety resolution.
6869 out := runtime .Str ("output" )
6970 outDir := runtime .Str ("output-dir" )
7071 if out != "" && outDir != "" {
71- return output . ErrValidation ( "--output and --output-dir cannot both be set" )
72+ return errs . NewValidationError ( errs . SubtypeInvalidArgument , "--output and --output-dir cannot both be set" ). WithParam ( "--output " )
7273 }
7374 if out != "" {
7475 if err := common .ValidateSafePath (runtime .FileIO (), out ); err != nil {
@@ -112,15 +113,15 @@ var MinutesDownload = common.Shortcut{
112113 explicitOutputPath = ""
113114 case statErr == nil && ! fi .IsDir ():
114115 if ! single {
115- return output . ErrValidation ( "--output %q is a file; batch mode expects a directory (use --output-dir)" , explicitOutputPath )
116+ return errs . NewValidationError ( errs . SubtypeInvalidArgument , "--output %q is a file; batch mode expects a directory (use --output-dir)" , explicitOutputPath ). WithParam ( "--output" )
116117 }
117118 case errors .Is (statErr , fs .ErrNotExist ):
118119 if ! single {
119120 explicitOutputDir = explicitOutputPath
120121 explicitOutputPath = ""
121122 }
122123 default :
123- return output . Errorf ( output . ExitAPI , "io_error" , " cannot access --output %q: %s" , explicitOutputPath , statErr )
124+ return errs . NewInternalError ( errs . SubtypeFileIO , "cannot access --output %q: %s" , explicitOutputPath , statErr ). WithCause ( statErr )
124125 }
125126 }
126127
@@ -137,6 +138,7 @@ var MinutesDownload = common.Shortcut{
137138 SizeBytes int64 `json:"size_bytes,omitempty"`
138139 DownloadURL string `json:"download_url,omitempty"`
139140 Error string `json:"error,omitempty"`
141+ err error // raw typed error for single-mode passthrough
140142 }
141143
142144 results := make ([]result , len (tokens ))
@@ -151,18 +153,18 @@ var MinutesDownload = common.Shortcut{
151153 // download URLs originate from the trusted Lark API, not user input.
152154 baseClient , err := runtime .Factory .HttpClient ()
153155 if err != nil {
154- return output . ErrNetwork ( "failed to get HTTP client: %s" , err )
156+ return errs . NewNetworkError ( errs . SubtypeNetworkTransport , "failed to get HTTP client: %s" , err ). WithCause ( err )
155157 }
156158 clonedClient := * baseClient
157159 clonedClient .Timeout = disableClientTimeout
158160 clonedClient .CheckRedirect = func (req * http.Request , via []* http.Request ) error {
159161 if len (via ) >= maxDownloadRedirects {
160- return fmt .Errorf ("too many redirects" )
162+ return fmt .Errorf ("too many redirects" ) //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error
161163 }
162164 if len (via ) > 0 {
163165 prev := via [len (via )- 1 ]
164166 if strings .EqualFold (prev .URL .Scheme , "https" ) && strings .EqualFold (req .URL .Scheme , "http" ) {
165- return fmt .Errorf ("redirect from https to http is not allowed" )
167+ return fmt .Errorf ("redirect from https to http is not allowed" ) //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error
166168 }
167169 }
168170 return validate .ValidateDownloadSourceURL (req .Context (), req .URL .String ())
@@ -193,7 +195,7 @@ var MinutesDownload = common.Shortcut{
193195
194196 downloadURL , err := fetchDownloadURL (ctx , runtime , token )
195197 if err != nil {
196- results [i ] = result {MinuteToken : token , Error : err .Error ()}
198+ results [i ] = result {MinuteToken : token , Error : err .Error (), err : err }
197199 continue
198200 }
199201
@@ -220,7 +222,7 @@ var MinutesDownload = common.Shortcut{
220222
221223 dl , err := downloadMediaFile (ctx , dlClient , downloadURL , token , opts )
222224 if err != nil {
223- results [i ] = result {MinuteToken : token , Error : err .Error ()}
225+ results [i ] = result {MinuteToken : token , Error : err .Error (), err : err }
224226 continue
225227 }
226228 results [i ] = result {
@@ -235,7 +237,10 @@ var MinutesDownload = common.Shortcut{
235237 if single {
236238 r := results [0 ]
237239 if r .Error != "" {
238- return output .ErrAPI (0 , r .Error , nil )
240+ if r .err != nil {
241+ return r .err // typed error from fetchDownloadURL/downloadMediaFile, exit code preserved
242+ }
243+ return runtime .OutPartialFailure (map [string ]interface {}{"downloads" : results }, & output.Meta {Count : len (results )})
239244 }
240245 if urlOnly {
241246 runtime .Out (map [string ]interface {}{
@@ -262,25 +267,27 @@ var MinutesDownload = common.Shortcut{
262267 }
263268 fmt .Fprintf (errOut , "[minutes +download] done: %d total, %d succeeded, %d failed\n " , len (results ), successCount , len (results )- successCount )
264269
265- runtime .OutFormat (map [string ]interface {}{"downloads" : results }, & output.Meta {Count : len (results )}, nil )
270+ outData := map [string ]interface {}{"downloads" : results }
271+ meta := & output.Meta {Count : len (results )}
266272 if successCount == 0 && len (results ) > 0 {
267- return output . ErrAPI ( 0 , fmt . Sprintf ( "all %d downloads failed" , len ( results )), nil )
273+ return runtime . OutPartialFailure ( outData , meta )
268274 }
275+ runtime .OutFormat (outData , meta , nil )
269276 return nil
270277 },
271278}
272279
273280// fetchDownloadURL retrieves the pre-signed download URL for a minute token.
274281func fetchDownloadURL (ctx context.Context , runtime * common.RuntimeContext , minuteToken string ) (string , error ) {
275- data , err := runtime .DoAPIJSON (http .MethodGet ,
282+ data , err := runtime .CallAPITyped (http .MethodGet ,
276283 fmt .Sprintf ("/open-apis/minutes/v1/minutes/%s/media" , validate .EncodePathSegment (minuteToken )),
277284 nil , nil )
278285 if err != nil {
279286 return "" , err
280287 }
281288 downloadURL := common .GetString (data , "download_url" )
282289 if downloadURL == "" {
283- return "" , output . Errorf ( output . ExitAPI , "api_error" , "API returned empty download_url for %s" , minuteToken )
290+ return "" , errs . NewInternalError ( errs . SubtypeInvalidResponse , "API returned empty download_url for %s" , minuteToken )
284291 }
285292 return downloadURL , nil
286293}
@@ -302,26 +309,26 @@ type downloadOpts struct {
302309// Filename resolution: opts.outputPath > Content-Disposition filename > Content-Type ext > <token>.media.
303310func downloadMediaFile (ctx context.Context , client * http.Client , downloadURL , minuteToken string , opts downloadOpts ) (* downloadResult , error ) {
304311 if err := validate .ValidateDownloadSourceURL (ctx , downloadURL ); err != nil {
305- return nil , output . ErrValidation ( "blocked download URL: %s" , err )
312+ return nil , errs . NewValidationError ( errs . SubtypeInvalidArgument , "blocked download URL: %s" , err ). WithCause ( err )
306313 }
307314
308315 req , err := http .NewRequestWithContext (ctx , http .MethodGet , downloadURL , nil )
309316 if err != nil {
310- return nil , output . ErrNetwork ( "invalid download URL: %s" , err )
317+ return nil , errs . NewNetworkError ( errs . SubtypeNetworkTransport , "invalid download URL: %s" , err ). WithCause ( err )
311318 }
312319
313320 resp , err := client .Do (req )
314321 if err != nil {
315- return nil , output . ErrNetwork ( "download failed: %s" , err )
322+ return nil , errs . NewNetworkError ( errs . SubtypeNetworkTransport , "download failed: %s" , err ). WithCause ( err )
316323 }
317324 defer resp .Body .Close ()
318325
319326 if resp .StatusCode < 200 || resp .StatusCode >= 300 {
320327 body , _ := io .ReadAll (io .LimitReader (resp .Body , 4096 ))
321328 if len (body ) > 0 {
322- return nil , output . ErrNetwork ( "download failed: HTTP %d: %s" , resp .StatusCode , strings .TrimSpace (string (body )))
329+ return nil , errs . NewNetworkError ( errs . SubtypeNetworkTransport , "download failed: HTTP %d: %s" , resp .StatusCode , strings .TrimSpace (string (body )))
323330 }
324- return nil , output . ErrNetwork ( "download failed: HTTP %d" , resp .StatusCode )
331+ return nil , errs . NewNetworkError ( errs . SubtypeNetworkTransport , "download failed: HTTP %d" , resp .StatusCode )
325332 }
326333
327334 // resolve output path
@@ -340,7 +347,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
340347
341348 if ! opts .overwrite {
342349 if _ , statErr := opts .fio .Stat (outputPath ); statErr == nil {
343- return nil , output . ErrValidation ( "output file already exists: %s (use --overwrite to replace)" , outputPath )
350+ return nil , errs . NewValidationError ( errs . SubtypeFailedPrecondition , "output file already exists: %s (use --overwrite to replace)" , outputPath )
344351 }
345352 }
346353
@@ -349,7 +356,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
349356 ContentLength : resp .ContentLength ,
350357 }, resp .Body )
351358 if err != nil {
352- return nil , common . WrapSaveErrorByCategory (err , "io" )
359+ return nil , minutesSaveError (err )
353360 }
354361 resolvedPath , err := opts .fio .ResolvePath (outputPath )
355362 if err != nil || resolvedPath == "" {
@@ -417,3 +424,21 @@ func extFromContentType(contentType string) string {
417424 }
418425 return ""
419426}
427+
428+ // minutesSaveError maps a FileIO.Save error to a typed error: path
429+ // validation failures are validation errors; mkdir/write failures are
430+ // internal file-I/O errors.
431+ func minutesSaveError (err error ) error {
432+ if err == nil {
433+ return nil
434+ }
435+ var me * fileio.MkdirError
436+ switch {
437+ case errors .Is (err , fileio .ErrPathValidation ):
438+ return errs .NewValidationError (errs .SubtypeInvalidArgument , "unsafe output path: %s" , err ).WithCause (err )
439+ case errors .As (err , & me ):
440+ return errs .NewInternalError (errs .SubtypeFileIO , "cannot create parent directory: %s" , err ).WithCause (err )
441+ default :
442+ return errs .NewInternalError (errs .SubtypeFileIO , "cannot create file: %s" , err ).WithCause (err )
443+ }
444+ }
0 commit comments