Skip to content

Commit 75802ca

Browse files
committed
feat(minutes,vc): emit typed error envelopes across both domains
Failures from the minutes and video-conference commands now surface as structured, typed errors carrying a stable category and subtype — spanning input validation, missing permissions, network and file-I/O failures, and remote API errors — so callers can branch on the error kind instead of parsing free-form text. Batch commands report partial failures explicitly, emitting per-item results with a non-zero exit instead of masking them.
1 parent 7c50b3d commit 75802ca

35 files changed

Lines changed: 1513 additions & 356 deletions

.golangci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,20 +73,20 @@ linters:
7373
- forbidigo
7474
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
7575
# Add a path when its migration is complete.
76-
- 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/)
76+
- 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/)
7777
text: errs-typed-only
7878
linters:
7979
- forbidigo
8080
# errs-no-bare-wrap enforced on paths fully migrated to typed final
8181
# errors. Scoped separately from errs-typed-only because cmd/auth/,
8282
# cmd/config/ still have residual fmt.Errorf and must not be caught.
83-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/|shortcuts/common/mcp_client\.go)
83+
- 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)
8484
text: errs-no-bare-wrap
8585
linters:
8686
- forbidigo
8787
# errs-no-legacy-helper enforced on domains whose shared validation/save
8888
# helpers have migrated to typed final errors.
89-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
89+
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
9090
text: errs-no-legacy-helper
9191
linters:
9292
- forbidigo
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package errclass
5+
6+
import "github.com/larksuite/cli/errs"
7+
8+
// minutesCodeMeta holds minutes-service Lark code → CodeMeta mappings.
9+
// Only codes whose meaning is stable across minutes endpoints are registered;
10+
// endpoint-specific codes fall back to CategoryAPI via BuildAPIError.
11+
// Command-specific messages, hints, and subtypes are layered on top via
12+
// per-command enrichment.
13+
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
14+
var minutesCodeMeta = map[int]CodeMeta{
15+
2091005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller lacks edit/read permission for the minute
16+
}
17+
18+
func init() { mergeCodeMeta(minutesCodeMeta, "minutes") }

internal/errclass/codemeta_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
7070
}
7171
}
7272

73+
func TestLookupCodeMeta_MinutesEndpointSpecificCode_NotGlobal(t *testing.T) {
74+
if got, ok := LookupCodeMeta(2091001); ok {
75+
t.Fatalf("LookupCodeMeta(2091001) = %+v, want unregistered; minutes endpoints use this code for different failures", got)
76+
}
77+
}
78+
7379
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
7480
got, ok := LookupCodeMeta(20050)
7581
if !ok {

internal/errclass/codemeta_vc.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package errclass
5+
6+
import "github.com/larksuite/cli/errs"
7+
8+
// vcCodeMeta holds vc-service Lark code → CodeMeta mappings.
9+
// Only codes whose meaning is verifiable from repo evidence are registered;
10+
// ambiguous codes (e.g. 124002 "recording still generating", which has no
11+
// precise taxonomy fit) fall back to CategoryAPI via BuildAPIError and rely on
12+
// per-command enrichment for a retry hint.
13+
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
14+
var vcCodeMeta = map[int]CodeMeta{
15+
121004: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // meeting has no minute file
16+
121005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller is not a participant / lacks view permission
17+
}
18+
19+
func init() { mergeCodeMeta(vcCodeMeta, "vc") }

lint/errscontract/rule_no_legacy_common_helper_call.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ var migratedCommonHelperPaths = []string{
1919
"shortcuts/calendar/",
2020
"shortcuts/drive/",
2121
"shortcuts/mail/",
22+
"shortcuts/minutes/",
2223
"shortcuts/okr/",
2324
"shortcuts/task/",
25+
"shortcuts/vc/",
2426
"shortcuts/whiteboard/",
2527
}
2628

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ var migratedEnvelopePaths = []string{
2020
"shortcuts/calendar/",
2121
"shortcuts/drive/",
2222
"shortcuts/mail/",
23+
"shortcuts/minutes/",
2324
"shortcuts/okr/",
2425
"shortcuts/task/",
26+
"shortcuts/vc/",
2527
"shortcuts/whiteboard/",
2628
"shortcuts/im/",
2729
}

shortcuts/common/validate.go

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -106,25 +106,6 @@ func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
106106
return MutuallyExclusiveTyped(rt, flags...)
107107
}
108108

109-
// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal].
110-
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
111-
//
112-
// Deprecated: use ValidatePageSizeTyped for typed error envelopes.
113-
func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
114-
s := rt.Str(flagName)
115-
if s == "" {
116-
return defaultVal, nil
117-
}
118-
n, err := strconv.Atoi(s)
119-
if err != nil {
120-
return 0, FlagErrorf("invalid --%s %q: must be an integer", flagName, s)
121-
}
122-
if n < minVal || n > maxVal {
123-
return 0, FlagErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal)
124-
}
125-
return n, nil
126-
}
127-
128109
// ValidatePageSizeTyped validates that the named flag (if set) is an integer within [minVal, maxVal].
129110
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
130111
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {

shortcuts/minutes/minutes_download.go

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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,29 +55,29 @@ 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 != "" {
74-
if err := common.ValidateSafePath(runtime.FileIO(), out); err != nil {
75+
if err := common.ValidateSafePathTyped(runtime.FileIO(), out); err != nil {
7576
return err
7677
}
7778
}
7879
if outDir != "" {
79-
if err := common.ValidateSafePath(runtime.FileIO(), outDir); err != nil {
80+
if err := common.ValidateSafePathTyped(runtime.FileIO(), outDir); err != nil {
8081
return err
8182
}
8283
}
@@ -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.
274281
func 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.
303310
func 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, common.WrapSaveErrorTyped(err)
353360
}
354361
resolvedPath, err := opts.fio.ResolvePath(outputPath)
355362
if err != nil || resolvedPath == "" {

0 commit comments

Comments
 (0)