Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,20 +73,20 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/|shortcuts/common/mcp_client\.go)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
text: errs-no-legacy-helper
linters:
- forbidigo
Expand Down
18 changes: 18 additions & 0 deletions internal/errclass/codemeta_minutes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package errclass

import "github.com/larksuite/cli/errs"

// minutesCodeMeta holds minutes-service Lark code → CodeMeta mappings.
// Only codes whose meaning is stable across minutes endpoints are registered;
// endpoint-specific codes fall back to CategoryAPI via BuildAPIError.
// Command-specific messages, hints, and subtypes are layered on top via
// per-command enrichment.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var minutesCodeMeta = map[int]CodeMeta{
2091005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller lacks edit/read permission for the minute
}

func init() { mergeCodeMeta(minutesCodeMeta, "minutes") }
6 changes: 6 additions & 0 deletions internal/errclass/codemeta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
}
}

func TestLookupCodeMeta_MinutesEndpointSpecificCode_NotGlobal(t *testing.T) {
if got, ok := LookupCodeMeta(2091001); ok {
t.Fatalf("LookupCodeMeta(2091001) = %+v, want unregistered; minutes endpoints use this code for different failures", got)
}
}

func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
got, ok := LookupCodeMeta(20050)
if !ok {
Expand Down
19 changes: 19 additions & 0 deletions internal/errclass/codemeta_vc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package errclass

import "github.com/larksuite/cli/errs"

// vcCodeMeta holds vc-service Lark code → CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes (e.g. 124002 "recording still generating", which has no
// precise taxonomy fit) fall back to CategoryAPI via BuildAPIError and rely on
// per-command enrichment for a retry hint.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var vcCodeMeta = map[int]CodeMeta{
121004: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // meeting has no minute file
121005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller is not a participant / lacks view permission
}

func init() { mergeCodeMeta(vcCodeMeta, "vc") }
2 changes: 2 additions & 0 deletions lint/errscontract/rule_no_legacy_common_helper_call.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ var migratedCommonHelperPaths = []string{
"shortcuts/calendar/",
"shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
}

Expand Down
2 changes: 2 additions & 0 deletions lint/errscontract/rule_no_legacy_envelope_literal.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ var migratedEnvelopePaths = []string{
"shortcuts/calendar/",
"shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/im/",
}
Expand Down
19 changes: 0 additions & 19 deletions shortcuts/common/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,25 +106,6 @@ func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
return MutuallyExclusiveTyped(rt, flags...)
}

// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal].
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
//
// Deprecated: use ValidatePageSizeTyped for typed error envelopes.
func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
s := rt.Str(flagName)
if s == "" {
return defaultVal, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, FlagErrorf("invalid --%s %q: must be an integer", flagName, s)
}
if n < minVal || n > maxVal {
return 0, FlagErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal)
}
return n, nil
}

// ValidatePageSizeTyped validates that the named flag (if set) is an integer within [minVal, maxVal].
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
Expand Down
57 changes: 32 additions & 25 deletions shortcuts/minutes/minutes_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"strings"
"time"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
Expand Down Expand Up @@ -54,29 +55,29 @@
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
if len(tokens) == 0 {
return output.ErrValidation("--minute-tokens is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens is required").WithParam("--minute-tokens")

Check warning on line 58 in shortcuts/minutes/minutes_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/minutes/minutes_download.go#L58

Added line #L58 was not covered by tests
}
if len(tokens) > maxBatchSize {
return output.ErrValidation("--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize).WithParam("--minute-tokens")

Check warning on line 61 in shortcuts/minutes/minutes_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/minutes/minutes_download.go#L61

Added line #L61 was not covered by tests
}
for _, token := range tokens {
if !validMinuteToken.MatchString(token) {
return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token).WithParam("--minute-tokens")
}
}
// Cheap checks first, then path-safety resolution.
out := runtime.Str("output")
outDir := runtime.Str("output-dir")
if out != "" && outDir != "" {
return output.ErrValidation("--output and --output-dir cannot both be set")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --output-dir cannot both be set").WithParam("--output")
}
if out != "" {
if err := common.ValidateSafePath(runtime.FileIO(), out); err != nil {
if err := common.ValidateSafePathTyped(runtime.FileIO(), out); err != nil {
return err
}
}
if outDir != "" {
if err := common.ValidateSafePath(runtime.FileIO(), outDir); err != nil {
if err := common.ValidateSafePathTyped(runtime.FileIO(), outDir); err != nil {
return err
}
}
Expand Down Expand Up @@ -112,15 +113,15 @@
explicitOutputPath = ""
case statErr == nil && !fi.IsDir():
if !single {
return output.ErrValidation("--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath).WithParam("--output")
}
case errors.Is(statErr, fs.ErrNotExist):
if !single {
explicitOutputDir = explicitOutputPath
explicitOutputPath = ""
}
default:
return output.Errorf(output.ExitAPI, "io_error", "cannot access --output %q: %s", explicitOutputPath, statErr)
return errs.NewInternalError(errs.SubtypeFileIO, "cannot access --output %q: %s", explicitOutputPath, statErr).WithCause(statErr)

Check warning on line 124 in shortcuts/minutes/minutes_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/minutes/minutes_download.go#L124

Added line #L124 was not covered by tests
}
}

Expand All @@ -137,6 +138,7 @@
SizeBytes int64 `json:"size_bytes,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
Error string `json:"error,omitempty"`
err error // raw typed error for single-mode passthrough
}

results := make([]result, len(tokens))
Expand All @@ -151,18 +153,18 @@
// download URLs originate from the trusted Lark API, not user input.
baseClient, err := runtime.Factory.HttpClient()
if err != nil {
return output.ErrNetwork("failed to get HTTP client: %s", err)
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to get HTTP client: %s", err).WithCause(err)

Check warning on line 156 in shortcuts/minutes/minutes_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/minutes/minutes_download.go#L156

Added line #L156 was not covered by tests
}
clonedClient := *baseClient
clonedClient.Timeout = disableClientTimeout
clonedClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= maxDownloadRedirects {
return fmt.Errorf("too many redirects")
return fmt.Errorf("too many redirects") //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error

Check warning on line 162 in shortcuts/minutes/minutes_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/minutes/minutes_download.go#L162

Added line #L162 was not covered by tests
}
if len(via) > 0 {
prev := via[len(via)-1]
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
return fmt.Errorf("redirect from https to http is not allowed")
return fmt.Errorf("redirect from https to http is not allowed") //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error

Check warning on line 167 in shortcuts/minutes/minutes_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/minutes/minutes_download.go#L167

Added line #L167 was not covered by tests
}
}
return validate.ValidateDownloadSourceURL(req.Context(), req.URL.String())
Expand Down Expand Up @@ -193,7 +195,7 @@

downloadURL, err := fetchDownloadURL(ctx, runtime, token)
if err != nil {
results[i] = result{MinuteToken: token, Error: err.Error()}
results[i] = result{MinuteToken: token, Error: err.Error(), err: err}
continue
}

Expand All @@ -220,7 +222,7 @@

dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts)
if err != nil {
results[i] = result{MinuteToken: token, Error: err.Error()}
results[i] = result{MinuteToken: token, Error: err.Error(), err: err}
continue
}
results[i] = result{
Expand All @@ -235,7 +237,10 @@
if single {
r := results[0]
if r.Error != "" {
return output.ErrAPI(0, r.Error, nil)
if r.err != nil {
return r.err // typed error from fetchDownloadURL/downloadMediaFile, exit code preserved
}
return runtime.OutPartialFailure(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)})

Check warning on line 243 in shortcuts/minutes/minutes_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/minutes/minutes_download.go#L243

Added line #L243 was not covered by tests
}
if urlOnly {
runtime.Out(map[string]interface{}{
Expand All @@ -262,25 +267,27 @@
}
fmt.Fprintf(errOut, "[minutes +download] done: %d total, %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)

runtime.OutFormat(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)}, nil)
outData := map[string]interface{}{"downloads": results}
meta := &output.Meta{Count: len(results)}
if successCount == 0 && len(results) > 0 {
return output.ErrAPI(0, fmt.Sprintf("all %d downloads failed", len(results)), nil)
return runtime.OutPartialFailure(outData, meta)
}
runtime.OutFormat(outData, meta, nil)
return nil
},
}

// fetchDownloadURL retrieves the pre-signed download URL for a minute token.
func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) (string, error) {
data, err := runtime.DoAPIJSON(http.MethodGet,
data, err := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/media", validate.EncodePathSegment(minuteToken)),
nil, nil)
if err != nil {
return "", err
}
downloadURL := common.GetString(data, "download_url")
if downloadURL == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "API returned empty download_url for %s", minuteToken)
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned empty download_url for %s", minuteToken)
}
return downloadURL, nil
}
Expand All @@ -302,26 +309,26 @@
// Filename resolution: opts.outputPath > Content-Disposition filename > Content-Type ext > <token>.media.
func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, minuteToken string, opts downloadOpts) (*downloadResult, error) {
if err := validate.ValidateDownloadSourceURL(ctx, downloadURL); err != nil {
return nil, output.ErrValidation("blocked download URL: %s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "blocked download URL: %s", err).WithCause(err)

Check warning on line 312 in shortcuts/minutes/minutes_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/minutes/minutes_download.go#L312

Added line #L312 was not covered by tests
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, output.ErrNetwork("invalid download URL: %s", err)
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "invalid download URL: %s", err).WithCause(err)

Check warning on line 317 in shortcuts/minutes/minutes_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/minutes/minutes_download.go#L317

Added line #L317 was not covered by tests
}

resp, err := client.Do(req)
if err != nil {
return nil, output.ErrNetwork("download failed: %s", err)
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: %s", err).WithCause(err)

Check warning on line 322 in shortcuts/minutes/minutes_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/minutes/minutes_download.go#L322

Added line #L322 was not covered by tests
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if len(body) > 0 {
return nil, output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil, output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)

Check warning on line 331 in shortcuts/minutes/minutes_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/minutes/minutes_download.go#L331

Added line #L331 was not covered by tests
}

// resolve output path
Expand All @@ -340,7 +347,7 @@

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)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", outputPath)
}
}

Expand All @@ -349,7 +356,7 @@
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return nil, common.WrapSaveErrorByCategory(err, "io")
return nil, common.WrapSaveErrorTyped(err)

Check warning on line 359 in shortcuts/minutes/minutes_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/minutes/minutes_download.go#L359

Added line #L359 was not covered by tests
}
resolvedPath, err := opts.fio.ResolvePath(outputPath)
if err != nil || resolvedPath == "" {
Expand Down
Loading
Loading