Compare commits

..

3 Commits

Author SHA1 Message Date
huangmengxuan
0ff9c84cd8 feat(skill): add relative-path-only rule to lark-shared security section
lark-cli rejects absolute paths for --file, --output, --output-dir,
and @file with 'unsafe file path'. Document this in lark-shared so
agents know to use cwd-relative paths or stdin for data input.

Change-Id: I50cf801c2c5d0e3cbb98a76e1752d410518c8636
2026-06-03 13:59:06 +08:00
huangmengxuan
175c9f6ffc feat(skill): add CRITICAL instruction to lark-task, lark-contact, lark-slides
Same enforcement as the previous commit — require reading reference
docs (or -h) before calling shortcuts. These three skills use
non-standard section headers but still have shortcut tables with
reference links.

Change-Id: I5170cc763c15e3030c4117a36af36c9f1e94501e
2026-06-03 13:59:06 +08:00
huangmengxuan
575bcc407b feat(skill): add CRITICAL instruction to enforce reference reading before shortcut execution
Evaluation data shows AI call failure rate <1% when reference docs are
read vs ~29% when not. Add a CRITICAL line to the Shortcuts section of
14 SKILL.md files and the skill template, requiring agents to read the
linked reference doc (or run -h for commands without one) before
invoking any shortcut.

Change-Id: Ia4204518eb43a9f6c8295b95633ee5d9cf2f5352
2026-06-03 13:59:06 +08:00
262 changed files with 3250 additions and 40521 deletions

View File

@@ -57,14 +57,6 @@ linters:
- path: internal/vfs/
linters:
- forbidigo
# internal/gen build-time generators (standalone `package main` run via
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
# impossible here: a structured error return needs os.Exit (also banned),
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
- path-except: shortcuts/
@@ -73,20 +65,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/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/)
- 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/calendar/helpers\.go|shortcuts/drive/)
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/drive/|shortcuts/mail/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
# it bans are still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/|shortcuts/mail/)
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
# still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/)
text: errs-no-legacy-helper
linters:
- forbidigo
@@ -115,17 +107,17 @@ linters:
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on migrated domains ──
# ── legacy shared error helpers banned on drive ──
# These helpers internally produce legacy output.Err* shapes, so they
# are invisible to the errs-typed-only ban above. Migrated domains use
# typed errs.* builders or domain-local file-I/O helpers instead; this
# prevents reintroduction while unmigrated domains continue to use the
# shared helpers until their later migration phase.
# are invisible to the errs-typed-only ban above. Drive has migrated its
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
# this prevents reintroduction. Other domains still use the shared
# helpers (migrated globally in a later phase), so this is drive-scoped.
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
shapes. Use typed errs.NewXxxError builders or a domain-local
file-I/O helper.
shapes. Use the typed errs.NewXxxError builders or the drive-local
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-

View File

@@ -2,43 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.48] - 2026-06-04
### Features
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
- **contact**: Add contact skill domain guidance (#1144)
### Bug Fixes
- **skills**: Use JSON skills list during update (#1251)
### Documentation
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
- **vc-agent**: Require explicit leave request (#1260)
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
## [v1.0.47] - 2026-06-03
### Features
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
- **base**: Add base block shortcuts (#1044)
- **im**: Complete card message format (#1198)
- **im**: Improve markdown guidance for messages (#1237)
- **vc**: Forward invite call-id on meeting join (#1243)
- **drive**: Emit typed error envelopes across the drive domain (#1205)
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
- **wiki**: Support `appid` member type (#1235)
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
- **config**: Validate credentials after `config init` (#1151)
### Bug Fixes
- **skills**: Recover empty fallback for skills to update (#1233)
## [v1.0.46] - 2026-06-02
### Features
@@ -1026,8 +989,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44

View File

@@ -117,13 +117,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
// inherited by every subcommand, turning unknown-flag errors into a
// structured "did you mean" envelope.
rootCmd.SilenceUsage = true
rootCmd.SetFlagErrorFunc(flagDidYouMean)
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {

View File

@@ -10,7 +10,6 @@ import (
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
)
const maxSuggestions = 3
@@ -29,7 +28,7 @@ func suggestEventKeys(input string) []string {
hits = append(hits, match{def.Key, 0})
continue
}
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
if d := levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d})
}
}
@@ -70,3 +69,34 @@ func unknownEventKeyErr(key string) error {
"Run 'lark-cli event list' to see available keys.",
)
}
// levenshtein computes classic edit distance (two-row DP).
func levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}

View File

@@ -10,6 +10,27 @@ import (
_ "github.com/larksuite/cli/events"
)
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "abc", 3},
{"kitten", "kitten", 0},
{"kitten", "sitten", 1},
{"kitten", "sitting", 3},
{"飞书", "飞书", 0},
{"飞书", "飞s", 1},
}
for _, tc := range cases {
if got := levenshtein(tc.a, tc.b); got != tc.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestSuggestEventKeys(t *testing.T) {
cases := []struct {
name string

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
func TestUnknownFlagName(t *testing.T) {
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --query", "query", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagName(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
}
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().String("find", "", "")
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
valid, _ := detail["valid_flags"].([]string)
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
t.Errorf("valid_flags should list find & range, got %v", valid)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "flag_error" {
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
}
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/deprecation"
)
// composePendingNotice must surface a deprecated-command alias under the
// "deprecated_command" key, with the migration target and a skill-update hint,
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
// without ever reading --help.
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(&deprecation.Notice{
Command: "+read",
Replacement: "+cells-get",
Skill: "lark-sheets",
})
got := composePendingNotice()
if got == nil {
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
}
entry, ok := got["deprecated_command"].(map[string]interface{})
if !ok {
t.Fatalf("missing deprecated_command key: %#v", got)
}
if entry["command"] != "+read" {
t.Errorf("command = %v, want +read", entry["command"])
}
if entry["replacement"] != "+cells-get" {
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
}
if entry["skill"] != "lark-sheets" {
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
}
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
t.Errorf("message missing skill-update hint: %q", msg)
}
}
// With nothing pending, the provider returns nil so no "_notice" field is
// emitted on a clean run.
func TestComposePendingNoticeEmpty(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
if got := composePendingNotice(); got != nil {
// update/skills pending are process-global; only assert the absence of
// our own key to stay robust against unrelated pending state.
if _, ok := got["deprecated_command"]; ok {
t.Fatalf("deprecated_command present after clear: %#v", got)
}
}
}

View File

@@ -18,17 +18,14 @@ import (
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const rootLong = `lark-cli — Lark/Feishu CLI tool.
@@ -72,15 +69,7 @@ COMMUNITY:
More help: lark-cli <command> --help`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
// from here. It stays nil in unit tests that invoke a RunE directly with
// explicit args — correct, since those don't exercise the whitelist path.
var rawInvocationArgs []string
func Execute() int {
rawInvocationArgs = os.Args[1:]
inv, err := BootstrapInvocationContext(os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
@@ -144,49 +133,29 @@ func setupNotices() {
skillscheck.Init(build.Version)
// Composed notice provider — emits keys only when each pending is set.
output.PendingNotice = composePendingNotice
}
// composePendingNotice merges all process-level pending notices (available
// update, skills/binary drift, deprecated-command alias) into the map surfaced
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
// Extracted from Execute so the composition is unit-testable.
func composePendingNotice() map[string]interface{} {
notice := map[string]interface{}{}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
output.PendingNotice = func() map[string]interface{} {
notice := map[string]interface{}{}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if len(notice) == 0 {
return nil
}
return notice
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if dep := deprecation.GetPending(); dep != nil {
entry := map[string]interface{}{
"command": dep.Command,
"message": dep.Message(),
"action": "lark-cli update",
}
if dep.Replacement != "" {
entry["replacement"] = dep.Replacement
}
if dep.Skill != "" {
entry["skill"] = dep.Skill
}
notice["deprecated_command"] = entry
}
if len(notice) == 0 {
return nil
}
return notice
}
// isCompletionCommand returns true if args indicate a shell completion request.
@@ -291,19 +260,6 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return exitErr.Code
}
// A backward-compat alias records its deprecation notice in PreRunE, which
// runs before cobra's required-flag validation — but a missing required flag
// fails before RunE and lands here, where the bare "Error:" line would drop
// the notice. When a deprecation is pending, route through the structured
// envelope so the migration hint still reaches the caller; all other errors
// keep the existing plain output.
if deprecation.GetPending() != nil {
output.WriteErrorEnvelope(errOut, &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
}, string(f.ResolvedIdentity))
return 1
}
fmt.Fprintln(errOut, "Error:", err)
return 1
}
@@ -345,12 +301,6 @@ func asExitError(err error) *output.ExitError {
func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
cmd.RunE = unknownSubcommandRunE
// Route an unknown subcommand to unknownSubcommandRunE even when flags
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
// consumes no flags itself, so unknown flags belong to the (missing)
// subcommand; whitelisting them here prevents cobra from erroring on the
// flag first and printing usage instead of our structured suggestion.
cmd.FParseErrWhitelist.UnknownFlags = true
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
@@ -370,89 +320,14 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
// they have moved to the typed surface.
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
// like the global --profile, legitimately prints help. But a flag that
// belongs to a (missing) subcommand is a user error: the guard's
// FParseErrWhitelist swallows such flags and leaves args empty, so without
// the checks below they would silently fall through to help + exit 0 —
// letting an agent mistake a malformed call (`im --format json`,
// `sheets --badflag`) for success. Recover the swallowed tokens from the
// raw invocation and fail structured instead.
flags := flagTokensInArgs(rawInvocationArgs)
if len(flags) == 0 {
return cmd.Help()
}
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
// Keep the same detail keys as flagDidYouMean's unknown_flag
// so a consumer keyed on Type can read a stable shape. The
// subcommand isn't resolved here, so suggestions/valid_flags
// have no meaningful universe to draw from — emit empty
// rather than the group's own (misleading) flags. unknown is
// the back-compat singular field; unknown_flags carries the
// full list when more than one flag was supplied.
"unknown": strings.Join(unknown, ", "),
"unknown_flags": unknown,
"command_path": cmd.CommandPath(),
"suggestions": []string{},
"valid_flags": []string{},
},
},
}
}
// The remaining flags are all defined somewhere in the tree. Those valid
// on the group itself or inherited (e.g. the global --profile) do not
// require a subcommand, so a bare group carrying only those still prints
// help. Anything left belongs to a subcommand that was omitted
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
// real, the subcommand is what's missing.
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
if len(misplaced) == 0 {
return cmd.Help()
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "missing_subcommand",
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
"command_path": cmd.CommandPath(),
"flags": misplaced,
"suggestions": []string{},
},
},
}
return cmd.Help()
}
unknown := args[0]
available, deprecated := availableSubcommandNames(cmd)
// Rank suggestions across both current and deprecated names so a mistyped
// legacy command (e.g. +raed → +read) still resolves; the alias stays
// runnable and self-flags via the _notice on execution.
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
available := availableSubcommandNames(cmd)
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
if len(suggestions) > 0 {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
strings.Join(suggestions, ", "), cmd.CommandPath())
}
detail := map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
}
// Only services with backward-compat aliases (currently sheets) carry a
// deprecated bucket; omit the key elsewhere so every other service's
// envelope is unchanged.
if len(deprecated) > 0 {
detail["deprecated"] = deprecated
if len(available) > 0 {
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
}
return &output.ExitError{
Code: output.ExitValidation,
@@ -460,114 +335,17 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: detail,
Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"available": available,
},
},
}
}
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
// rawArgs, stopping at the "--" positional terminator. Whether a flag is
// defined is not considered (see unknownFlagTokens for that). A pure group
// with any flag token but no subcommand is a user error — a pure group
// consumes no flags of its own, so the flag must belong to a subcommand — so
// the caller fails structured instead of falling through to help.
func flagTokensInArgs(rawArgs []string) []string {
var toks []string
for _, a := range rawArgs {
if a == "--" {
break // everything after -- is positional
}
if len(a) < 2 || a[0] != '-' {
continue
}
toks = append(toks, a)
}
return toks
}
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
// the suggestion path; the side effect is that flags before a subcommand are
// swallowed. This recovers the genuinely-unknown ones so the caller can name
// them in a "did you mean" envelope.
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
var unknown []string
for _, a := range flagTokensInArgs(rawArgs) {
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
if name != "" && !flagDefinedInTree(cmd, name) {
unknown = append(unknown, a)
}
}
return unknown
}
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
// group and therefore not requiring a subcommand.
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
short := len(name) == 1
lookup := func(fs *pflag.FlagSet) bool {
if short {
return fs.ShorthandLookup(name) != nil
}
return fs.Lookup(name) != nil
}
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
}
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
// omitting the subcommand they belong to (`im --format json`). Global flags
// valid on the bare group (e.g. --profile) are excluded so
// `lark-cli --profile p im` still prints help rather than erroring.
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
var misplaced []string
for _, a := range flagTokensInArgs(rawArgs) {
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
if name == "" || flagKnownOnGroup(cmd, name) {
continue
}
if flagDefinedInTree(cmd, name) {
misplaced = append(misplaced, a)
}
}
return misplaced
}
// flagDefinedInTree reports whether name is defined on cmd, its inherited
// (persistent) flags, or any direct subcommand. The subcommand case covers a
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
// --format is injected on every leaf shortcut, not on the group — so only a
// genuinely unknown flag like `sheets --badflag` is reported.
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
short := len(name) == 1
known := func(c *cobra.Command, inherited bool) bool {
fs := c.Flags()
if inherited {
fs = c.InheritedFlags()
}
if short {
return fs.ShorthandLookup(name) != nil
}
return fs.Lookup(name) != nil
}
if known(cmd, false) || known(cmd, true) {
return true
}
for _, c := range cmd.Commands() {
if known(c, false) {
return true
}
}
return false
}
// availableSubcommandNames returns the invokable subcommand names of cmd, split
// into current commands and backward-compatibility aliases (those tagged into
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
// sorted; hidden commands plus help/completion are omitted.
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
func availableSubcommandNames(cmd *cobra.Command) []string {
subs := make([]string, 0, len(cmd.Commands()))
for _, c := range cmd.Commands() {
if c.Hidden || !c.IsAvailableCommand() {
continue
@@ -576,95 +354,10 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
if name == "help" || name == "completion" {
continue
}
if cmdutil.IsDeprecatedCommand(c) {
deprecated = append(deprecated, name)
} else {
available = append(available, name)
}
subs = append(subs, name)
}
sort.Strings(available)
sort.Strings(deprecated)
return available, deprecated
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
// --find, where edit distance alone finds nothing). Other flag errors stay
// structured but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "flag_error",
Message: ferr.Error(),
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
},
}
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
Hint: hint,
Detail: map[string]any{
"unknown": "--" + name,
"command_path": c.CommandPath(),
"suggestions": suggestions,
"valid_flags": valid,
},
},
}
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
// those structured but generic — hallucinated flags are essentially always long.
//
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
// silently fails and unknown flags degrade to a generic flag_error — re-verify
// this prefix when bumping cobra.
func unknownFlagName(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
rest := msg[i+len(p):]
if j := strings.IndexAny(rest, " \t"); j >= 0 {
rest = rest[:j]
}
return rest, true
}
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
// the valid_flags detail).
func visibleFlagNames(c *cobra.Command) []string {
var names []string
c.Flags().VisitAll(func(f *pflag.Flag) {
if !f.Hidden {
names = append(names, f.Name)
}
})
sort.Strings(names)
return names
sort.Strings(subs)
return subs
}
// installTipsHelpFunc wraps the default help function to append a TIPS section

View File

@@ -21,7 +21,6 @@ import (
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
)
@@ -269,54 +268,6 @@ func (f *failingWriter) Write(p []byte) (int, error) {
return len(p), nil
}
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
// backward-compat alias that fails on a cobra-level required flag (which
// short-circuits before RunE) still routes through the structured envelope,
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
// switches to WriteErrorEnvelope when a deprecation is pending — so the
// migration notice is no longer dropped on the plain "Error:" line.
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
// nor an *output.ExitError, so it reaches the legacy fallback.
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
}
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
}
}
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
// fix does not reshape every unrecognized cobra error.
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
if !strings.HasPrefix(errOut.String(), "Error:") {
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
}
}
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
// stderr write fails mid-envelope, handleRootError still returns the typed
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the

View File

@@ -11,7 +11,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -73,149 +72,6 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
}
}
func TestUnknownFlagTokens(t *testing.T) {
_, drive, _ := newGroupTree()
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
// the subcommand) is distinguished from a genuinely unknown one.
for _, c := range drive.Commands() {
if c.Name() == "+search" {
c.Flags().String("query", "", "")
}
}
cases := []struct {
name string
rawArgs []string
want []string
}{
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
{"no flags at all", []string{"drive"}, nil},
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := unknownFlagTokens(drive, tc.rawArgs)
if len(got) != len(tc.want) {
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
}
}
})
}
}
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
// --badflag, so RunE sees no args; the guard must recover it from
// rawInvocationArgs and fail structured rather than print help + exit 0.
rawInvocationArgs = []string{"drive", "--badflag"}
t.Cleanup(func() { rawInvocationArgs = nil })
err := drive.RunE(drive, nil)
if err == nil {
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
}
if !strings.Contains(err.Error(), "unknown flag") {
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
}
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
}
if detail["unknown"] != "--badflag" {
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
}
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
}
for _, key := range []string{"suggestions", "valid_flags"} {
if _, present := detail[key]; !present {
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
}
}
}
func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) {
_, drive, _ := newGroupTree()
// --query is defined on the +search subcommand, so it is a *valid* flag that
// was placed before the (omitted) subcommand. Unlike an unknown flag, this
// must still fail structured (missing_subcommand) rather than fall through to
// help + exit 0 — `drive --query x` is a malformed call, not a help request.
for _, c := range drive.Commands() {
if c.Name() == "+search" {
c.Flags().String("query", "", "")
}
}
installUnknownSubcommandGuard(drive.Root())
rawInvocationArgs = []string{"drive", "--query", "x"}
t.Cleanup(func() { rawInvocationArgs = nil })
err := drive.RunE(drive, nil)
if err == nil {
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
}
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
}
}
// A bare group carrying only a group-valid global flag (e.g. the inherited
// --profile) is not missing a subcommand — those flags do not belong to a
// subcommand — so it must print help, not fail with missing_subcommand.
func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive
installUnknownSubcommandGuard(drive.Root())
rawInvocationArgs = []string{"--profile", "p", "drive"}
t.Cleanup(func() { rawInvocationArgs = nil })
var buf bytes.Buffer
drive.SetOut(&buf)
drive.SetErr(&buf)
if err := drive.RunE(drive, nil); err != nil {
t.Fatalf("bare group with only a global flag should print help, got error: %v", err)
}
if !strings.Contains(buf.String(), "drive ops") {
t.Errorf("expected help output, got:\n%s", buf.String())
}
}
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
@@ -257,11 +113,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
}
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
// back to pointing at --help; the full machine-readable list lives in
// detail.available below (which also excludes hidden commands).
if !strings.Contains(exitErr.Detail.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "+secret") {
t.Error("hidden commands must not appear in the hint")
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
@@ -308,7 +164,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
)
got, _ := availableSubcommandNames(root)
got := availableSubcommandNames(root)
want := []string{"alpha", "gamma"}
if len(got) != len(want) {
t.Fatalf("expected %v, got %v", want, got)
@@ -319,61 +175,3 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
}
}
}
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
root.AddCommand(
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
)
available, deprecated := availableSubcommandNames(root)
if len(available) != 1 || available[0] != "+new-cmd" {
t.Errorf("available = %v, want [+new-cmd]", available)
}
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
}
}
// unknownSubcommandRunE must split current vs deprecated subcommands into
// separate detail buckets, while suggestions still rank across both so a
// mistyped legacy alias resolves.
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
svc.AddCommand(
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
)
err := unknownSubcommandRunE(svc, []string{"+reat"})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
}
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
t.Errorf("available = %v, want [+cells-get]", available)
}
deprecated, ok := detail["deprecated"].([]string)
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
t.Errorf("deprecated = %v, want [+read]", deprecated)
}
// suggestions rank across both buckets: "+reat" is closest to +read.
suggestions, _ := detail["suggestions"].([]string)
found := false
for _, s := range suggestions {
if s == "+read" {
found = true
}
}
if !found {
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
}
}

View File

@@ -61,8 +61,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
switch strings.Join(args, " ") {
case "-y skills add https://open.feishu.cn --list":
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
case "-y skills ls -g --json":
r.Stdout.WriteString(`[{"name":"lark-calendar","path":"/tmp/lark-calendar","scope":"global","agents":["Codex"]},{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`)
case "-y skills ls -g":
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
default:

2
go.mod
View File

@@ -14,7 +14,7 @@ require (
github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0

View File

@@ -5,7 +5,6 @@ package cmdpolicy
import (
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/suggest"
)
// suggestRisk returns the closest valid Risk literal by edit distance
@@ -21,9 +20,9 @@ func suggestRisk(bad string) string {
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
}
best := string(candidates[0])
bestDist := suggest.Levenshtein(lowered, best)
bestDist := levenshtein(lowered, best)
for _, c := range candidates[1:] {
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
if d := levenshtein(lowered, string(c)); d < bestDist {
bestDist, best = d, string(c)
}
}
@@ -41,3 +40,47 @@ func toLower(s string) string {
}
return string(b)
}
// levenshtein computes the classic edit distance between two strings.
// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set
// makes raw performance irrelevant — clarity beats trickiness here.
func levenshtein(a, b string) int {
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
prev := make([]int, len(b)+1)
curr := make([]int, len(b)+1)
for j := 0; j <= len(b); j++ {
prev[j] = j
}
for i := 1; i <= len(a); i++ {
curr[0] = i
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = min3(
prev[j]+1, // deletion
curr[j-1]+1, // insertion
prev[j-1]+cost, // substitution
)
}
prev, curr = curr, prev
}
return prev[len(b)]
}
func min3(a, b, c int) int {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
return m
}

View File

@@ -29,3 +29,23 @@ func TestSuggestRisk(t *testing.T) {
}
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"", "abc", 3},
{"abc", "", 3},
{"abc", "abc", 0},
{"wrtie", "write", 2},
{"kitten", "sitting", 3},
}
for _, c := range cases {
got := levenshtein(c.a, c.b)
if got != c.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}

View File

@@ -1,18 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import "github.com/spf13/cobra"
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
// command — one kept alive for users whose skill predates a refactor. Service
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
// rendering and unknown-subcommand suggestions read it to separate these
// aliases from the current commands.
const DeprecatedGroupID = "deprecated"
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
func IsDeprecatedCommand(c *cobra.Command) bool {
return c != nil && c.GroupID == DeprecatedGroupID
}

View File

@@ -1,57 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deprecation carries a process-level notice that the command currently
// being executed is a backward-compatibility alias, kept alive for users whose
// skill predates a refactor. The notice is surfaced in JSON output envelopes via
// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck.
//
// A CLI process runs exactly one shortcut, so a single process-level slot is
// sufficient: the command's Execute records the notice before producing output,
// and the output layer reads it back when building the envelope.
package deprecation
import (
"strings"
"sync/atomic"
)
// Notice describes a deprecated command alias and the current command that
// replaces it. Replacement and Skill are optional.
type Notice struct {
Command string `json:"command"`
Replacement string `json:"replacement,omitempty"`
Skill string `json:"skill,omitempty"`
}
// Message returns a single-line, AI-agent-parseable description of the alias
// plus the canonical fix (update the skill). Mirrors the style of
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
func (n *Notice) Message() string {
var b strings.Builder
b.WriteString(n.Command)
b.WriteString(" is a pre-refactor compatibility alias")
if n.Replacement != "" {
b.WriteString("; use ")
b.WriteString(n.Replacement)
b.WriteString(" instead")
}
if n.Skill != "" {
b.WriteString("; update your ")
b.WriteString(n.Skill)
b.WriteString(" skill, run: lark-cli update")
} else {
b.WriteString("; update your skill, run: lark-cli update")
}
return b.String()
}
// pending stores the latest deprecation notice for the current process.
var pending atomic.Pointer[Notice]
// SetPending stores the notice for consumption by output decorators.
// Pass nil to clear.
func SetPending(n *Notice) { pending.Store(n) }
// GetPending returns the pending deprecation notice, or nil.
func GetPending() *Notice { return pending.Load() }

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package deprecation
import "testing"
func TestNoticeMessage(t *testing.T) {
tests := []struct {
name string
notice Notice
want string
}{
{
name: "replacement and skill",
notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"},
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update",
},
{
name: "no replacement",
notice: Notice{Command: "+read", Skill: "lark-sheets"},
want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update",
},
{
name: "no skill",
notice: Notice{Command: "+read", Replacement: "+cells-get"},
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.notice.Message(); got != tt.want {
t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want)
}
})
}
}
func TestSetGetPending(t *testing.T) {
t.Cleanup(func() { SetPending(nil) })
SetPending(nil)
if got := GetPending(); got != nil {
t.Fatalf("expected nil pending after clear, got %#v", got)
}
n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"}
SetPending(n)
got := GetPending()
if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" {
t.Fatalf("GetPending() = %#v, want %#v", got, n)
}
SetPending(nil)
if GetPending() != nil {
t.Fatal("expected nil after clearing")
}
}

View File

@@ -244,8 +244,6 @@ func APIHint(subtype errs.Subtype) string {
return "operate on source and target within the same tenant and region/unit"
case errs.SubtypeCrossBrand:
return "operate on source and target within the same brand environment"
case errs.SubtypeQuotaExceeded:
return "reduce the request volume or free quota, then retry after the relevant quota resets"
}
return ""
}

View File

@@ -1,20 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// mailCodeMeta holds mail-service Lark code -> CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
var mailCodeMeta = map[int]CodeMeta{
1234013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // mailbox not found or not active
1236007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily send count exceeded
1236008: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily external recipient count exceeded
1236009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant daily external recipient count exceeded
1236010: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // mail quota limit
1236013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant storage limit exceeded
}
func init() { mergeCodeMeta(mailCodeMeta, "mail") }

View File

@@ -165,10 +165,6 @@ func (u *Updater) ListGlobalSkills() *NpmResult {
return u.runSkillsListGlobal()
}
func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g", "--json")
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil {

View File

@@ -188,13 +188,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
},
want: "-y skills ls -g",
},
{
name: "list global json",
run: func(u *Updater) *NpmResult {
return u.ListGlobalSkillsJSON()
},
want: "-y skills ls -g --json",
},
{
name: "install skill primary",
run: func(u *Updater) *NpmResult {

View File

@@ -4,7 +4,6 @@
package skillscheck
import (
"encoding/json"
"fmt"
"regexp"
"sort"
@@ -58,28 +57,6 @@ func ParseSkillsList(text string) []string {
return nil
}
func ParseGlobalSkillsJSON(text string) []string {
type globalSkill struct {
Name string `json:"name"`
}
var skills []globalSkill
if err := json.Unmarshal([]byte(text), &skills); err != nil {
return nil
}
seen := map[string]bool{}
for _, skill := range skills {
candidate := strings.TrimSpace(skill.Name)
if candidate == "" || !skillNamePattern.MatchString(candidate) {
continue
}
seen[candidate] = true
}
return sortedKeys(seen)
}
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
func parseGlobalSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -100,11 +77,8 @@ func parseGlobalSkillsList(lines []string) []string {
continue
}
if strings.HasPrefix(trimmed, "Agents:") {
continue
}
if isGlobalSkillsSectionHeader(trimmed) {
// Skip indented lines (Agents: ...)
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
continue
}
@@ -117,24 +91,21 @@ func parseGlobalSkillsList(lines []string) []string {
candidate := parts[0]
// Validate and add
if candidate == "" || !skillNamePattern.MatchString(candidate) {
if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") {
continue
}
if !skillNamePattern.MatchString(candidate) {
continue
}
if at := strings.Index(candidate, "@"); at > 0 {
candidate = candidate[:at]
}
seen[candidate] = true
}
return sortedKeys(seen)
}
func isGlobalSkillsSectionHeader(line string) bool {
switch line {
case "General", "Project", "Local":
return true
default:
return false
}
}
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
func parseOfficialSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -224,7 +195,6 @@ func PlanSync(input SyncInput) SyncPlan {
type SkillsRunner interface {
ListOfficialSkills() *selfupdate.NpmResult
ListGlobalSkillsJSON() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(nameList []string) *selfupdate.NpmResult
InstallAllSkills() *selfupdate.NpmResult
@@ -269,9 +239,10 @@ func SyncSkills(opts SyncOptions) *SyncResult {
}
// --- Step 2: List local (installed) skills ---
local, ok := listLocalSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
local := []string{}
localResult := opts.Runner.ListGlobalSkills()
if localResult != nil && localResult.Err == nil {
local = ParseSkillsList(localResult.Stdout.String())
}
// --- Step 3: Read previous state ---
@@ -327,24 +298,6 @@ func SyncSkills(opts SyncOptions) *SyncResult {
return result
}
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
jsonResult := runner.ListGlobalSkillsJSON()
if jsonResult != nil && jsonResult.Err == nil {
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
return local, true
}
}
textResult := runner.ListGlobalSkills()
if textResult != nil && textResult.Err == nil {
if local := ParseSkillsList(textResult.Stdout.String()); len(local) > 0 {
return local, true
}
}
return nil, false
}
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
// when incremental sync is not possible. On success it writes a state file so that
// subsequent syncs can use incremental mode. When official is non-nil the state

View File

@@ -67,49 +67,6 @@ func TestParseGlobalSkillsListWithANSI(t *testing.T) {
}
}
func TestParseGlobalSkillsListWithIndentedGroupedRows(t *testing.T) {
input := `Global Skills
General
lark-apps ~/.agents/skills/lark-apps
lark-base ~/.agents/skills/lark-base
`
got := ParseSkillsList(input)
want := []string{"lark-apps", "lark-base"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (indented Global Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsJSON(t *testing.T) {
input := `[
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
{"name":"lark-mail@1.2.3","path":"/Users/example/.agents/skills/lark-mail","scope":"global","agents":["Codex"]},
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
{"name":" lark-base ","path":"/Users/example/.agents/skills/lark-base","scope":"global","agents":["Codex"]},
{"name":""},
{"name":" "},
{"name":"bad skill"}
]`
got := ParseGlobalSkillsJSON(input)
want := []string{"lark-base", "lark-calendar", "lark-mail@1.2.3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseGlobalSkillsJSON() = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
for _, input := range []string{
`not json`,
`{"name":"lark-calendar"}`,
`[]`,
} {
if got := ParseGlobalSkillsJSON(input); len(got) != 0 {
t.Fatalf("ParseGlobalSkillsJSON(%q) = %#v, want empty", input, got)
}
}
}
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{
@@ -156,18 +113,14 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
}
type fakeSkillsRunner struct {
officialOut string
globalJSONOut string
globalOut string
officialErr error
globalJSONErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
listedGlobalJSON int
listedGlobalText int
officialOut string
globalOut string
officialErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
}
func officialSkillsOutput(names ...string) string {
@@ -193,19 +146,6 @@ func globalSkillsOutput(names ...string) string {
return b.String()
}
func globalSkillsJSONOutput(names ...string) string {
var b strings.Builder
b.WriteString("[")
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"path":"/Users/example/.agents/skills/%s","scope":"global","agents":["Codex"]}`, name, name)
}
b.WriteString("]")
return b.String()
}
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut)
@@ -213,16 +153,7 @@ func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
return r
}
func (f *fakeSkillsRunner) ListGlobalSkillsJSON() *selfupdate.NpmResult {
f.listedGlobalJSON++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalJSONOut)
r.Err = f.globalJSONErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
f.listedGlobalText++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalOut)
r.Err = f.globalErr
@@ -255,9 +186,8 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
globalOut: globalSkillsOutput("lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
@@ -269,12 +199,6 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"})
if runner.listedGlobalJSON != 1 {
t.Fatalf("listedGlobalJSON = %d, want 1", runner.listedGlobalJSON)
}
if runner.listedGlobalText != 0 {
t.Fatalf("listedGlobalText = %d, want 0 when JSON list succeeds", runner.listedGlobalText)
}
state, readable, err := ReadState()
if err != nil || !readable {
@@ -338,73 +262,47 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
}
}
func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed"),
globalOut: globalSkillsOutput("lark-calendar"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalErr: fmt.Errorf("global list failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
if runner.listedGlobalJSON != 1 || runner.listedGlobalText != 1 {
t.Fatalf("listed JSON/text = %d/%d, want 1/1", runner.listedGlobalJSON, runner.listedGlobalText)
assertStrings(t, result.SkippedDeleted, []string{})
}
func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: "Some unrecognized output format\n",
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
}
}
func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if strings.Contains(result.Detail, "/Users/example") || strings.Contains(result.Detail, "agents") {
t.Fatalf("SyncSkills() detail leaks local command output: %q", result.Detail)
}
}
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: `[]`,
globalOut: "Some unrecognized output format\n",
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
if len(runner.installed) != 1 {
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
}
}
@@ -446,7 +344,6 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
@@ -478,7 +375,6 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
@@ -577,7 +473,6 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
@@ -602,7 +497,6 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
@@ -643,9 +537,8 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
}
runner2 := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
}
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
if result2.Action != "synced" {

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package suggest provides the shared "did you mean" primitives: a rune-aware
// Levenshtein edit distance and a prefix-weighted Closest ranker. It is the
// single home for these so cmd, cmd/event, and internal/cmdpolicy stop each
// carrying their own copy.
package suggest
import "sort"
// Levenshtein computes the classic edit distance between two strings. It is
// rune-aware, so it is correct for multi-byte input.
func Levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}
// Closest returns up to maxN of candidates that plausibly match typed, ranked
// by shared-prefix length (desc) then edit distance (asc), keeping only
// reasonably-close ones.
//
// Shared prefix is weighted first on purpose: hallucinated names are often
// semantically close but lexically far (e.g. "+cells-find" vs "+cells-search",
// "--with-styles" vs nothing close), where the common prefix is the strongest
// signal of intent that raw edit distance misses.
func Closest(typed string, candidates []string, maxN int) []string {
type scored struct {
name string
prefix int
dist int
}
limit := editLimit(typed)
ranked := make([]scored, 0, len(candidates))
for _, c := range candidates {
p := sharedPrefixLen(typed, c)
d := Levenshtein(typed, c)
// Keep only plausible matches: a meaningful shared prefix, or an edit
// distance within budget. Drop everything else so the hint stays short.
if p >= 3 || d <= limit {
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
}
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].prefix != ranked[j].prefix {
return ranked[i].prefix > ranked[j].prefix
}
if ranked[i].dist != ranked[j].dist {
return ranked[i].dist < ranked[j].dist
}
return ranked[i].name < ranked[j].name
})
if maxN <= 0 || maxN > len(ranked) {
maxN = len(ranked)
}
out := make([]string, 0, maxN)
for _, s := range ranked[:maxN] {
out = append(out, s.name)
}
return out
}
// editLimit allows roughly one third of the typed length in edits (min 2), so
// short names tolerate a couple of typos and longer ones proportionally more.
func editLimit(s string) int {
if l := len([]rune(s)) / 3; l > 2 {
return l
}
return 2
}
func sharedPrefixLen(a, b string) int {
ra, rb := []rune(a), []rune(b)
n := 0
for n < len(ra) && n < len(rb) && ra[n] == rb[n] {
n++
}
return n
}

View File

@@ -1,74 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package suggest
import (
"slices"
"testing"
)
func TestClosest_HallucinatedSharesPrefix(t *testing.T) {
cmds := []string{
"+cells-get", "+cells-set", "+cells-search", "+cells-replace",
"+cells-clear", "+cells-merge", "+csv-get", "+chart-create",
"+pivot-create", "+sheet-info",
}
// "+cells-find" is semantically +cells-search but lexically far; the shared
// "+cells-" prefix should still surface the right family (incl. +cells-search).
got := Closest("+cells-find", cmds, 6)
if len(got) == 0 || len(got) > 6 {
t.Fatalf("expected 1..6 suggestions, got %v", got)
}
if !slices.Contains(got, "+cells-search") {
t.Errorf("expected +cells-search among suggestions, got %v", got)
}
for _, s := range got {
if len(s) < 7 || s[:7] != "+cells-" {
t.Errorf("suggestion %q does not share the +cells- prefix", s)
}
}
}
func TestClosest_TypoRanksExactNeighborFirst(t *testing.T) {
got := Closest("+cell-get", []string{"+cells-get", "+cells-set", "+csv-get", "+sheet-info"}, 3)
if len(got) == 0 || got[0] != "+cells-get" {
t.Errorf("expected +cells-get first for typo +cell-get, got %v", got)
}
}
func TestClosest_NoPlausibleMatch(t *testing.T) {
if got := Closest("+zzzzzz", []string{"+cells-get", "+csv-get"}, 6); len(got) != 0 {
t.Errorf("expected no suggestions for unrelated input, got %v", got)
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "abc", 3},
{"abc", "", 3},
{"abc", "abc", 0},
{"kitten", "sitting", 3},
{"cell-get", "cells-get", 1},
{"--query", "--find", 5},
{"飞书", "飞书", 0}, // rune-aware: multi-byte equal
{"飞书", "飞s", 1}, // one rune substitution, not byte count
}
for _, c := range cases {
if d := Levenshtein(c.a, c.b); d != c.want {
t.Errorf("Levenshtein(%q,%q) = %d, want %d", c.a, c.b, d, c.want)
}
}
}
func TestSharedPrefixLen(t *testing.T) {
if got := sharedPrefixLen("+cells-find", "+cells-search"); got != 7 {
t.Errorf("sharedPrefixLen = %d, want 7", got)
}
if got := sharedPrefixLen("abc", "xyz"); got != 0 {
t.Errorf("sharedPrefixLen = %d, want 0", got)
}
}

View File

@@ -1,139 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"strings"
)
// migratedCommonHelperPaths lists source-tree prefixes whose command validation
// has migrated to typed errs.* envelopes. On these paths, calls to common's
// legacy validation/save helpers are forbidden; callers must use the typed
// common replacements or construct an errs.* typed error directly.
var migratedCommonHelperPaths = []string{
"shortcuts/drive/",
"shortcuts/mail/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
var legacyCommonHelperReplacements = map[string]string{
"FlagErrorf": "common.ValidationErrorf",
"MutuallyExclusive": "common.MutuallyExclusiveTyped",
"AtLeastOne": "common.AtLeastOneTyped",
"ExactlyOne": "common.ExactlyOneTyped",
"ValidatePageSize": "common.ValidatePageSizeTyped",
"ValidateChatID": "common.ValidateChatIDTyped",
"ValidateUserID": "common.ValidateUserIDTyped",
"ValidateSafePath": "common.ValidateSafePathTyped",
"RejectDangerousChars": "common.RejectDangerousCharsTyped",
"WrapInputStatError": "common.WrapInputStatErrorTyped",
"WrapSaveErrorByCategory": "common.WrapSaveErrorTyped",
"ResolveOpenIDs": "common.ResolveOpenIDsTyped",
"HandleApiResult": "runtime.CallAPITyped",
}
// CheckNoLegacyCommonHelperCall flags any reference to common's legacy helper
// APIs on migrated paths — direct calls and function-value references alike,
// so `f := common.FlagErrorf; f(...)` cannot slip past the guard. These
// helpers return legacy output envelopes or bare errors, so migrated domains
// should use their typed-aware replacements.
func CheckNoLegacyCommonHelperCall(path, src string) []Violation {
if !isMigratedCommonHelperPath(path) || strings.HasSuffix(path, "_test.go") {
return nil
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
if err != nil {
return nil
}
localNames, dotImported := resolveCommonNames(file)
var out []Violation
report := func(pos token.Pos, name, replacement string) {
out = append(out, Violation{
Rule: "no_legacy_common_helper_call",
Action: ActionReject,
File: path,
Line: fset.Position(pos).Line,
Message: "common." + name + " returns a legacy error shape and is forbidden on migrated paths",
Suggestion: "replace common." + name + " with " + replacement + " or a typed errs.* constructor",
})
}
// Pass 1: qualified references (common.X / alias.X). Record every
// selector field so the dot-import pass below never mistakes another
// package's same-named field for a common helper.
selFields := make(map[*ast.Ident]struct{})
ast.Inspect(file, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
selFields[sel.Sel] = struct{}{}
x, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
if _, bound := localNames[x.Name]; !bound {
return true
}
if replacement, ok := legacyCommonHelperReplacements[sel.Sel.Name]; ok {
report(sel.Pos(), sel.Sel.Name, replacement)
}
return true
})
// Pass 2: unqualified references under a dot import.
if dotImported {
ast.Inspect(file, func(n ast.Node) bool {
ident, ok := n.(*ast.Ident)
if !ok {
return true
}
if _, isField := selFields[ident]; isField {
return true
}
if replacement, ok := legacyCommonHelperReplacements[ident.Name]; ok {
report(ident.Pos(), ident.Name, replacement)
}
return true
})
}
return out
}
func isMigratedCommonHelperPath(path string) bool {
p := strings.ReplaceAll(path, "\\", "/")
for _, prefix := range migratedCommonHelperPaths {
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
return true
}
}
return false
}
func resolveCommonNames(file *ast.File) (map[string]struct{}, bool) {
names := make(map[string]struct{})
dotImported := false
for _, imp := range file.Imports {
if imp.Path == nil {
continue
}
p := strings.Trim(imp.Path.Value, "`\"")
if p != commonImportPath {
continue
}
switch {
case imp.Name == nil:
names["common"] = struct{}{}
case imp.Name.Name == ".":
dotImported = true
case imp.Name.Name == "_":
default:
names[imp.Name.Name] = struct{}{}
}
}
return names, dotImported
}

View File

@@ -17,7 +17,6 @@ import (
// appending their path prefix here.
var migratedEnvelopePaths = []string{
"shortcuts/drive/",
"shortcuts/mail/",
}
// legacyOutputImportPath is the import path of the package that declares the

View File

@@ -877,129 +877,3 @@ func boom(runtime *common.RuntimeContext) error {
t.Errorf("test files must be skipped, got: %+v", v)
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *testing.T) {
helpers := []string{
"FlagErrorf",
"MutuallyExclusive",
"AtLeastOne",
"ExactlyOne",
"ValidatePageSize",
"ValidateChatID",
"ValidateUserID",
"ValidateSafePath",
"RejectDangerousChars",
"WrapInputStatError",
"WrapSaveErrorByCategory",
"ResolveOpenIDs",
"HandleApiResult",
}
paths := []string{
"shortcuts/drive/drive_search.go",
"shortcuts/mail/mail_send.go",
}
for _, path := range paths {
for _, helper := range helpers {
t.Run(path+"_"+helper, func(t *testing.T) {
src := `package migrated
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.` + helper + `()
}
`
v := CheckNoLegacyCommonHelperCall(path, src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for %s on %s, got %d: %+v", helper, path, len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "common."+helper) {
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
}
})
}
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package im
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.FlagErrorf("legacy allowed until domain migrates")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must pass, got: %+v", v)
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsTypedHelpersOnMigratedPath(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.ValidationErrorf("typed")
common.MutuallyExclusiveTyped(nil, "a", "b")
common.ValidateChatIDTyped("--chat-ids", "oc_abc")
common.ResolveOpenIDsTyped("--user-ids", nil, nil)
common.WrapSaveErrorTyped(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 0 {
t.Errorf("typed helpers must pass, got: %+v", v)
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsAliasedImport(t *testing.T) {
src := `package drive
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
c.FlagErrorf("legacy")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for aliased common import, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsDotImport(t *testing.T) {
src := `package drive
import . "github.com/larksuite/cli/shortcuts/common"
func boom() {
FlagErrorf("legacy")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for dot-imported common, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsFunctionValueReference(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom() error {
f := common.FlagErrorf
return f("legacy")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
}
}

View File

@@ -108,7 +108,6 @@ func ScanRepo(root string) ([]Violation, error) {
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...)
all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...)
all = append(all, CheckNoLegacyCommonHelperCall(rel, string(src))...)
// Typed-error invariants — self-scope to errs/ + classify.go.
all = append(all, CheckNilSafeError(rel, string(src))...)
all = append(all, CheckUnwrapSymmetry(rel, string(src))...)

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.48",
"version": "1.0.46",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -1,42 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockCreate = common.Shortcut{
Service: "base",
Command: "+base-block-create",
Description: "Create a block",
Risk: "write",
Scopes: []string{"base:block:create"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "type", Desc: "resource type", Required: true, Enum: baseBlockTypeEnums},
{Name: "name", Desc: "block name", Required: true},
{Name: "parent-id", Desc: "folder block id; when omitted, create at root"},
},
Tips: []string{
"Example: lark-cli base +base-block-create --base-token <base_token> --type folder --name \"Project Docs\"",
"Example: lark-cli base +base-block-create --base-token <base_token> --type table --name \"Tasks\"",
"Example: lark-cli base +base-block-create --base-token <base_token> --type docx --name \"Spec\" --parent-id <folder_block_id>",
"Example: lark-cli base +base-block-create --base-token <base_token> --type dashboard --name \"Metrics\"",
"Example: lark-cli base +base-block-create --base-token <base_token> --type workflow --name \"Approval Flow\"",
"Creates a folder, table, docx, dashboard, or workflow entry.",
"Do not pass null for --parent-id. Omit it to create at the root level.",
"Created resources still use their own commands for content operations, such as table/field/record/docx/dashboard/workflow commands.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBaseBlockCreate(runtime)
},
DryRun: dryRunBaseBlockCreate,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockCreate(runtime)
},
}

View File

@@ -1,35 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockDelete = common.Shortcut{
Service: "base",
Command: "+base-block-delete",
Description: "Delete a block",
Risk: "high-risk-write",
Scopes: []string{"base:block:delete"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
baseBlockIDFlag(true),
},
Tips: []string{
"Example: lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
"Deletes the block identified by --block-id.",
"Recursive folder deletion is not supported. If a folder is not empty, move or delete its children first.",
"Different block types may have independent backing resources; deletion follows backend semantics.",
"Use +base-block-list first when you need to confirm the target block id.",
"If the user already explicitly confirmed this exact delete target, pass --yes without asking again.",
},
DryRun: dryRunBaseBlockDelete,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockDelete(runtime)
},
}

View File

@@ -1,43 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockList = common.Shortcut{
Service: "base",
Command: "+base-block-list",
Description: "List blocks in a base",
Risk: "read",
Scopes: []string{"base:block:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "type", Desc: "filter by resource type", Enum: baseBlockTypeEnums},
{Name: "parent-id", Desc: "folder block id; when omitted, list all blocks"},
},
Tips: []string{
"Example: lark-cli base +base-block-list --base-token <base_token>",
"Example: lark-cli base +base-block-list --base-token <base_token> --type table",
"Example: lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
`JQ crop: lark-cli base +base-block-list --base-token <base_token> | jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
`JQ crop docx: lark-cli base +base-block-list --base-token <base_token> --type docx | jq '.blocks[] | {name, docx_token}'`,
"Blocks are resources managed directly by the base, such as folder, table, docx, dashboard, and workflow.",
"For table, dashboard, and workflow blocks, returned id is the table-id, dashboard-id, or workflow-id used by the corresponding commands.",
"For docx blocks, use the returned docx_token with docx commands.",
"For folder blocks, pass the returned id as --parent-id when creating, listing, or moving blocks inside that folder.",
"This command returns the full backend list. It intentionally does not expose limit or offset.",
"Pass --type to list only one resource type.",
"Pass --parent-id to list only direct children of a folder.",
"Dashboard blocks are chart/widget blocks inside a dashboard; use +dashboard-block-* for those.",
},
DryRun: dryRunBaseBlockList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockList(runtime)
},
}

View File

@@ -1,42 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockMove = common.Shortcut{
Service: "base",
Command: "+base-block-move",
Description: "Move a block",
Risk: "write",
Scopes: []string{"base:block:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
baseBlockIDFlag(true),
{Name: "parent-id", Desc: "target folder block id; when omitted, move to root"},
{Name: "before-id", Desc: "sibling block id; move the block before this sibling in the target folder/root order"},
{Name: "after-id", Desc: "sibling block id; move the block after this sibling in the target folder/root order"},
},
Tips: []string{
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
"Omit --parent-id to move the block to root; do not pass null.",
"--before-id and --after-id are mutually exclusive.",
"When moving a folder, its children remain under that folder.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBaseBlockMove(runtime)
},
DryRun: dryRunBaseBlockMove,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockMove(runtime)
},
}

View File

@@ -1,179 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var baseBlockTypeEnums = []string{"folder", "table", "docx", "dashboard", "workflow"}
func baseBlockIDFlag(required bool) common.Flag {
return common.Flag{Name: "block-id", Desc: "block id", Required: required}
}
func dryRunBaseBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
Body(buildBaseBlockListBody(runtime)).
Set("base_token", runtime.Str("base-token"))
}
func dryRunBaseBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks").
Body(buildBaseBlockCreateBody(runtime)).
Set("base_token", runtime.Str("base-token"))
}
func dryRunBaseBlockMove(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/move").
Body(buildBaseBlockMoveBody(runtime)).
Set("base_token", runtime.Str("base-token")).
Set("block_id", runtime.Str("block-id"))
}
func dryRunBaseBlockRename(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/rename").
Body(map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}).
Set("base_token", runtime.Str("base-token")).
Set("block_id", runtime.Str("block-id"))
}
func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/blocks/:block_id").
Set("base_token", runtime.Str("base-token")).
Set("block_id", runtime.Str("block-id"))
}
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("name")) == "" {
return common.FlagErrorf("--name must not be blank")
}
if strings.TrimSpace(runtime.Str("type")) == "" {
return common.FlagErrorf("--type must not be blank")
}
return nil
}
func validateBaseBlockMove(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
return common.FlagErrorf("--before-id and --after-id are mutually exclusive")
}
return nil
}
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("name")) == "" {
return common.FlagErrorf("--name must not be blank")
}
return nil
}
func executeBaseBlockList(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", "list"), nil, buildBaseBlockListBody(runtime))
if err != nil {
return err
}
filterBaseBlockListData(data, strings.TrimSpace(runtime.Str("type")))
runtime.Out(data, nil)
return nil
}
func executeBaseBlockCreate(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks"), nil, buildBaseBlockCreateBody(runtime))
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "created": true}, nil)
return nil
}
func executeBaseBlockMove(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "move"), nil, buildBaseBlockMoveBody(runtime))
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "moved": true}, nil)
return nil
}
func executeBaseBlockRename(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "rename"), nil, map[string]interface{}{
"name": strings.TrimSpace(runtime.Str("name")),
})
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "renamed": true}, nil)
return nil
}
func executeBaseBlockDelete(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "deleted": true}, nil)
return nil
}
func buildBaseBlockListBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
body["parent_id"] = parentID
}
return body
}
func filterBaseBlockListData(data map[string]interface{}, blockType string) {
if blockType == "" {
return
}
blocks, ok := data["blocks"].([]interface{})
if !ok {
return
}
filtered := make([]interface{}, 0, len(blocks))
for _, block := range blocks {
blockMap, ok := block.(map[string]interface{})
if !ok || blockMap["type"] != blockType {
continue
}
filtered = append(filtered, block)
}
data["blocks"] = filtered
data["total"] = len(filtered)
}
func buildBaseBlockCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"type": strings.TrimSpace(runtime.Str("type")),
"name": strings.TrimSpace(runtime.Str("name")),
}
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
body["parent_id"] = parentID
}
return body
}
func buildBaseBlockMoveBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{"parent_id": nil}
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
body["parent_id"] = parentID
}
if beforeID := strings.TrimSpace(runtime.Str("before-id")); beforeID != "" {
body["before_id"] = beforeID
}
if afterID := strings.TrimSpace(runtime.Str("after-id")); afterID != "" {
body["after_id"] = afterID
}
return body
}

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockRename = common.Shortcut{
Service: "base",
Command: "+base-block-rename",
Description: "Rename a block",
Risk: "write",
Scopes: []string{"base:block:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
baseBlockIDFlag(true),
{Name: "name", Desc: "new unique block name; must not duplicate another block name in this base", Required: true},
},
Tips: []string{
"Example: lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name \"New name\"",
"Renames the block identified by --block-id.",
"Block names must be unique in the base; use +base-block-list first when you need to check existing names.",
"Use +base-block-list first when you need to resolve the target block id from a visible name.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBaseBlockRename(runtime)
},
DryRun: dryRunBaseBlockRename,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockRename(runtime)
},
}

View File

@@ -32,29 +32,6 @@ func TestDryRunTableOps(t *testing.T) {
assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1")
}
func TestDryRunBaseBlockOps(t *testing.T) {
ctx := context.Background()
listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockList(ctx, listRT), "POST /open-apis/base/v3/bases/app_x/blocks/list")
listFolderRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "parent-id": "bfl_1", "type": "docx"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockList(ctx, listFolderRT), "POST /open-apis/base/v3/bases/app_x/blocks/list", `"parent_id":"bfl_1"`)
createRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "type": "docx", "name": "Spec", "parent-id": "bfl_1"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockCreate(ctx, createRT), "POST /open-apis/base/v3/bases/app_x/blocks", `"type":"docx"`, `"name":"Spec"`, `"parent_id":"bfl_1"`)
moveRootRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveRootRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":null`)
moveAfterRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "parent-id": "bfl_1", "after-id": "blk_0"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveAfterRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":"bfl_1"`, `"after_id":"blk_0"`)
renameRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "name": "New name"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockRename(ctx, renameRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/rename", `"name":"New name"`)
assertDryRunContains(t, dryRunBaseBlockDelete(ctx, renameRT), "DELETE /open-apis/base/v3/bases/app_x/blocks/blk_1")
}
func TestDryRunFieldOps(t *testing.T) {
ctx := context.Background()

View File

@@ -411,108 +411,6 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
return body
}
func TestBaseBlockExecuteShortcuts(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
listStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/list",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec"},
map[string]interface{}{"id": "blk_folder", "type": "folder", "name": "Folder"},
},
"total": 2,
},
},
}
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "type": "docx", "name": "Spec"},
},
}
moveStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "parent_id": "bfl_1"},
},
}
renameStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/rename",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "name": "Final Spec"},
},
}
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc"},
},
}
for _, stub := range []*httpmock.Stub{listStub, createStub, moveStub, renameStub, deleteStub} {
reg.Register(stub)
}
if err := runShortcut(t, BaseBaseBlockList, []string{"+base-block-list", "--base-token", "app_x", "--parent-id", "bfl_1", "--type", "docx"}, factory, stdout); err != nil {
t.Fatalf("list err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"total": 1`) || !strings.Contains(got, `"blk_doc"`) || strings.Contains(got, `"blk_folder"`) {
t.Fatalf("list stdout=%s", got)
}
if body := decodeCapturedJSONBody(t, listStub); body["parent_id"] != "bfl_1" || body["type"] != nil {
t.Fatalf("list body=%#v", body)
}
if err := runShortcut(t, BaseBaseBlockCreate, []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " Spec ", "--parent-id", "bfl_1"}, factory, stdout); err != nil {
t.Fatalf("create err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"blk_doc"`) {
t.Fatalf("create stdout=%s", got)
}
createBody := decodeCapturedJSONBody(t, createStub)
if createBody["type"] != "docx" || createBody["name"] != "Spec" || createBody["parent_id"] != "bfl_1" {
t.Fatalf("create body=%#v", createBody)
}
if err := runShortcut(t, BaseBaseBlockMove, []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--parent-id", "bfl_1", "--after-id", "blk_prev"}, factory, stdout); err != nil {
t.Fatalf("move err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"moved": true`) {
t.Fatalf("move stdout=%s", got)
}
moveBody := decodeCapturedJSONBody(t, moveStub)
if moveBody["parent_id"] != "bfl_1" || moveBody["after_id"] != "blk_prev" {
t.Fatalf("move body=%#v", moveBody)
}
if err := runShortcut(t, BaseBaseBlockRename, []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " Final Spec "}, factory, stdout); err != nil {
t.Fatalf("rename err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"renamed": true`) || !strings.Contains(got, `"Final Spec"`) {
t.Fatalf("rename stdout=%s", got)
}
if body := decodeCapturedJSONBody(t, renameStub); body["name"] != "Final Spec" {
t.Fatalf("rename body=%#v", body)
}
if err := runShortcut(t, BaseBaseBlockDelete, []string{"+base-block-delete", "--base-token", "app_x", "--block-id", "blk_doc", "--yes"}, factory, stdout); err != nil {
t.Fatalf("delete err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"blk_doc"`) {
t.Fatalf("delete stdout=%s", got)
}
}
func TestBaseHistoryExecute(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{

View File

@@ -133,7 +133,6 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
func TestShortcutsCatalog(t *testing.T) {
shortcuts := Shortcuts()
want := []string{
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
@@ -189,7 +188,6 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
BaseBaseBlockDelete.Command: BaseBaseBlockDelete.Risk,
BaseRoleDelete.Command: BaseRoleDelete.Risk,
}
@@ -243,30 +241,6 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
}
}
func TestBaseBlockMoveRejectsBeforeAndAfter(t *testing.T) {
runtime := newBaseTestRuntime(
map[string]string{"before-id": "blk_before", "after-id": "blk_after"},
nil,
nil,
)
err := validateBaseBlockMove(runtime)
if err == nil || !strings.Contains(err.Error(), "--before-id and --after-id are mutually exclusive") {
t.Fatalf("err=%v", err)
}
}
func TestBaseBlockCreateAndRenameRequireName(t *testing.T) {
createRT := newBaseTestRuntime(map[string]string{"type": "folder", "name": " "}, nil, nil)
if err := validateBaseBlockCreate(createRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
t.Fatalf("create err=%v", err)
}
renameRT := newBaseTestRuntime(map[string]string{"name": " "}, nil, nil)
if err := validateBaseBlockRename(renameRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
t.Fatalf("rename err=%v", err)
}
}
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
@@ -754,79 +728,6 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
}
}
func TestBaseBlockHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "list",
shortcut: BaseBaseBlockList,
wantTips: []string{
"lark-cli base +base-block-list --base-token <base_token>",
"lark-cli base +base-block-list --base-token <base_token> --type table",
"lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
`jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
`--type docx | jq '.blocks[] | {name, docx_token}'`,
"returned id is the table-id, dashboard-id, or workflow-id",
"For docx blocks, use the returned docx_token with docx commands.",
},
},
{
name: "create",
shortcut: BaseBaseBlockCreate,
wantTips: []string{
`lark-cli base +base-block-create --base-token <base_token> --type folder --name "Project Docs"`,
`lark-cli base +base-block-create --base-token <base_token> --type table --name "Tasks"`,
`lark-cli base +base-block-create --base-token <base_token> --type docx --name "Spec" --parent-id <folder_block_id>`,
`lark-cli base +base-block-create --base-token <base_token> --type dashboard --name "Metrics"`,
`lark-cli base +base-block-create --base-token <base_token> --type workflow --name "Approval Flow"`,
},
},
{
name: "move",
shortcut: BaseBaseBlockMove,
wantTips: []string{
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
},
},
{
name: "rename",
shortcut: BaseBaseBlockRename,
wantTips: []string{
`lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name "New name"`,
},
},
{
name: "delete",
shortcut: BaseBaseBlockDelete,
wantTips: []string{
"lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
"Recursive folder deletion is not supported.",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})

View File

@@ -8,11 +8,6 @@ import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all base shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
BaseBaseBlockList,
BaseBaseBlockCreate,
BaseBaseBlockMove,
BaseBaseBlockRename,
BaseBaseBlockDelete,
BaseTableList,
BaseTableGet,
BaseTableCreate,

View File

@@ -164,9 +164,6 @@ func CheckApiError(w io.Writer, result interface{}, action string) bool {
}
// HandleApiResult checks for network/API errors and returns the "data" field.
//
// Deprecated: use RuntimeContext.CallAPITyped (or ClassifyAPIResponse for
// self-driven requests) for typed error envelopes.
func HandleApiResult(result interface{}, err error, action string) (map[string]interface{}, error) {
if err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)

View File

@@ -15,7 +15,6 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
)
@@ -58,7 +57,6 @@ type DriveMediaMultipartUploadConfig struct {
Reader io.Reader
}
// Deprecated: use UploadDriveMediaAllTyped for typed error envelopes.
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
var fileReader io.Reader
if cfg.Reader != nil {
@@ -100,52 +98,6 @@ func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig)
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadAllAction)
}
// UploadDriveMediaAllTyped is the typed-error counterpart of
// UploadDriveMediaAll: file-open failures surface as typed validation errors,
// transport failures as typed network errors, and API failures are classified
// via ClassifyAPIResponse so subtype / code / log_id survive on the error.
func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
var fileReader io.Reader
if cfg.Reader != nil {
fileReader = cfg.Reader
} else {
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return "", WrapInputStatErrorTyped(err)
}
defer f.Close()
fileReader = f
}
fd := larkcore.NewFormdata()
fd.AddField("file_name", cfg.FileName)
fd.AddField("parent_type", cfg.ParentType)
fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize))
if cfg.ParentNode != nil {
fd.AddField("parent_node", *cfg.ParentNode)
}
if cfg.Extra != "" {
fd.AddField("extra", cfg.Extra)
}
fd.AddFile("file", fileReader)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return "", prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return "", prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction)
}
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
}
// Deprecated: use UploadDriveMediaMultipartTyped for typed error envelopes.
func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
// upload_prepare expects parent_node to be present even when the caller wants
// the service default/root behavior, so multipart callers pass an explicit
@@ -178,43 +130,6 @@ func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartU
return finishDriveMediaMultipartUpload(runtime, session.UploadID, session.BlockNum)
}
// UploadDriveMediaMultipartTyped is the typed-error counterpart of
// UploadDriveMediaMultipart: prepare/finish failures come back typed from
// CallAPITyped, malformed session plans surface as invalid-response internal
// errors, and per-part transport/API failures are classified the same way as
// UploadDriveMediaAllTyped.
func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
// upload_prepare expects parent_node to be present even when the caller wants
// the service default/root behavior, so multipart callers pass an explicit
// string instead of relying on field omission like upload_all does.
prepareBody := map[string]interface{}{
"file_name": cfg.FileName,
"parent_type": cfg.ParentType,
"parent_node": cfg.ParentNode,
"size": cfg.FileSize,
}
if cfg.Extra != "" {
prepareBody["extra"] = cfg.Extra
}
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
if err != nil {
return "", err
}
session, err := parseDriveMediaMultipartUploadSessionTyped(data)
if err != nil {
return "", err
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
return "", err
}
return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
}
func ParseDriveMediaMultipartUploadSession(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
// The backend chooses both chunk size and chunk count. Validate them once so
// the streaming loop can follow the returned plan without re-checking shape.
@@ -365,122 +280,3 @@ func finishDriveMediaMultipartUpload(runtime *RuntimeContext, uploadID string, b
}
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadFinishAction)
}
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
// message so callers see which upload step failed. Non-typed errors are
// returned unchanged.
func prefixDriveMediaUploadProblem(err error, action string) error {
if p, ok := errs.ProblemOf(err); ok {
p.Message = action + ": " + p.Message
}
return err
}
// parseDriveMediaMultipartUploadSessionTyped validates the upload_prepare
// session plan like ParseDriveMediaMultipartUploadSession, but reports a
// malformed plan as a typed invalid-response internal error.
func parseDriveMediaMultipartUploadSessionTyped(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
session := DriveMediaMultipartUploadSession{
UploadID: GetString(data, "upload_id"),
BlockSize: int64(GetFloat(data, "block_size")),
BlockNum: int(GetFloat(data, "block_num")),
}
if session.UploadID == "" {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: no upload_id returned")
}
if session.BlockSize <= 0 {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
}
if session.BlockNum <= 0 {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_num returned")
}
return session, nil
}
// extractDriveMediaUploadFileTokenTyped mirrors ExtractDriveMediaUploadFileToken
// with a typed invalid-response internal error for a missing file_token.
func extractDriveMediaUploadFileTokenTyped(data map[string]interface{}, action string) (string, error) {
fileToken := GetString(data, "file_token")
if fileToken == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: no file_token returned", action)
}
return fileToken, nil
}
// uploadDriveMediaMultipartPartsTyped mirrors uploadDriveMediaMultipartParts
// with typed errors for file-open, file-read, and per-part upload failures.
func uploadDriveMediaMultipartPartsTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error {
var r io.Reader
if cfg.Reader != nil {
r = cfg.Reader
} else {
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return WrapInputStatErrorTyped(err)
}
defer f.Close()
r = f
}
maxInt := int64(^uint(0) >> 1)
bufferSize := session.BlockSize
if bufferSize <= 0 || bufferSize > maxInt {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
}
buffer := make([]byte, int(bufferSize))
remaining := cfg.FileSize
// Follow the server-declared block plan exactly; upload_finish expects the
// same block count returned by upload_prepare.
for seq := 0; seq < session.BlockNum; seq++ {
chunkSize := session.BlockSize
if remaining > 0 && chunkSize > remaining {
chunkSize = remaining
}
n, readErr := io.ReadFull(r, buffer[:int(chunkSize)])
if readErr != nil {
return WrapInputStatErrorTyped(readErr)
}
if err := uploadDriveMediaMultipartPartTyped(runtime, session.UploadID, seq, buffer[:n]); err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
remaining -= int64(n)
}
return nil
}
func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error {
fd := larkcore.NewFormdata()
fd.AddField("upload_id", uploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", len(chunk)))
fd.AddFile("file", bytes.NewReader(chunk))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadPartAction)
}
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return prefixDriveMediaUploadProblem(err, driveMediaUploadPartAction)
}
return nil
}
func finishDriveMediaMultipartUploadTyped(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})
if err != nil {
return "", err
}
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadFinishAction)
}

View File

@@ -1,305 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func TestUploadDriveMediaAllTypedWithInMemoryContent(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_typed_123"},
},
})
payload := []byte{0x89, 0x50, 0x4e, 0x47}
fileToken, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err != nil {
t.Fatalf("UploadDriveMediaAllTyped() error: %v", err)
}
if fileToken != "file_typed_123" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_typed_123")
}
}
func TestUploadDriveMediaAllTypedClassifiesAPIFailure(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 999,
"msg": "upload rejected",
},
})
payload := []byte{0x01}
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Category != errs.CategoryAPI {
t.Fatalf("category = %s, want api", p.Category)
}
if p.Code != 999 {
t.Fatalf("code = %d, want 999", p.Code)
}
if !strings.HasPrefix(p.Message, "upload media failed: ") || !strings.Contains(p.Message, "upload rejected") {
t.Fatalf("message = %q, want action prefix and server msg", p.Message)
}
}
func TestUploadDriveMediaAllTypedFileOpenFailure(t *testing.T) {
runtime, _ := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
FilePath: "missing.bin",
FileName: "missing.bin",
FileSize: 1,
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected typed validation error, got %T (%v)", err, err)
}
}
func TestUploadDriveMediaMultipartTypedBuildsPreparePartsAndFinish(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
size := MaxDriveMediaUploadSinglePartSize + 1
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_typed_1",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_typed_multi"},
},
})
payload := bytes.Repeat([]byte{0xCD}, int(size))
fileToken, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: size,
ParentType: "docx_image",
ParentNode: "",
})
if err != nil {
t.Fatalf("UploadDriveMediaMultipartTyped() error: %v", err)
}
if fileToken != "file_typed_multi" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_typed_multi")
}
}
func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data map[string]interface{}
wantText string
}{
{
name: "missing upload id",
data: map[string]interface{}{
"block_size": 4 * 1024 * 1024,
"block_num": 6,
},
wantText: "upload prepare failed: no upload_id returned",
},
{
name: "missing block size",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_num": 6,
},
wantText: "upload prepare failed: invalid block_size returned",
},
{
name: "missing block num",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_size": 4 * 1024 * 1024,
},
wantText: "upload prepare failed: invalid block_num returned",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
}
})
}
}
func TestUploadDriveMediaMultipartTypedPartAPIFailure(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 999,
"msg": "chunk rejected",
},
})
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: "large.bin",
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
ParentType: "ccm_import_open",
ParentNode: "",
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Category != errs.CategoryAPI || p.Code != 999 {
t.Fatalf("category/code = %s/%d, want api/999", p.Category, p.Code)
}
if !strings.HasPrefix(p.Message, "upload media part failed: ") || !strings.Contains(p.Message, "chunk rejected") {
t.Fatalf("message = %q, want action prefix and server msg", p.Message)
}
}
func TestUploadDriveMediaMultipartTypedFinishRequiresFileToken(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: "large.bin",
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
ParentType: "ccm_import_open",
ParentNode: "",
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
}
if !strings.Contains(p.Message, "upload media finish failed: no file_token returned") {
t.Fatalf("message = %q", p.Message)
}
}

View File

@@ -30,7 +30,6 @@ import (
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// RuntimeContext provides helpers for shortcut execution.
@@ -73,16 +72,6 @@ func (ctx *RuntimeContext) IsBot() bool {
return ctx.As().IsBot()
}
// Command returns the shortcut command name as cobra knows it (e.g.
// "+pivot-create"). Used by per-service helpers (e.g. sheets schema
// validation) that key off the shortcut identity.
func (ctx *RuntimeContext) Command() string {
if ctx.Cmd == nil {
return ""
}
return ctx.Cmd.Name()
}
// UserOpenId returns the current user's open_id from config.
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
@@ -211,12 +200,6 @@ func (ctx *RuntimeContext) Int(name string) int {
return v
}
// Float64 returns a float64 flag value (non-integer numbers).
func (ctx *RuntimeContext) Float64(name string) float64 {
v, _ := ctx.Cmd.Flags().GetFloat64(name)
return v
}
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
func (ctx *RuntimeContext) StrArray(name string) []string {
v, _ := ctx.Cmd.Flags().GetStringArray(name)
@@ -642,8 +625,6 @@ func WrapOpenError(err error, pathMsg, readMsg string) error {
// - Other errors → readMsg prefix (default "cannot read file")
//
// Pass an optional readMsg to override the non-path-validation message prefix.
//
// Deprecated: use WrapInputStatErrorTyped for typed error envelopes.
func WrapInputStatError(err error, readMsg ...string) error {
if err == nil {
return nil
@@ -658,28 +639,9 @@ func WrapInputStatError(err error, readMsg ...string) error {
return output.ErrValidation("%s: %s", msg, err)
}
// WrapInputStatErrorTyped wraps a FileIO.Stat/Open error for input file validation.
func WrapInputStatErrorTyped(err error, readMsg ...string) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).
WithCause(err)
}
msg := "cannot read file"
if len(readMsg) > 0 && readMsg[0] != "" {
msg = readMsg[0]
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).
WithCause(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).
//
// Deprecated: use WrapSaveErrorTyped for typed error envelopes.
func WrapSaveErrorByCategory(err error, category string) error {
if err == nil {
return nil
@@ -695,28 +657,6 @@ func WrapSaveErrorByCategory(err error, category string) error {
}
}
// WrapSaveErrorTyped maps a FileIO.Save error to typed validation/internal errors.
// Unlike WrapSaveErrorByCategory, non-path failures always emit the canonical
// "internal" wire type: call sites migrating from a custom category
// (e.g. "io", "api_error") change their envelope's type field.
func WrapSaveErrorTyped(err error) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
WithCause(err)
case errors.As(err, &me):
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).
WithCause(err)
default:
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).
WithCause(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
@@ -955,29 +895,6 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
return runShortcut(cmd, f, &shortcut, botOnly)
},
}
if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil {
onInvoke := shortcut.OnInvoke
relaxRequiredForSchema := shortcut.PrintFlagSchema != nil
// PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses:
// - OnInvoke: fire a side effect (e.g. a deprecation notice) that must
// surface even when the call later fails on a missing required flag.
// - --print-schema: pure local introspection; relax the required-flag
// gate so callers don't fill in unrelated flags just to ask for a
// schema (clearing the annotation here is the supported opt-out).
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
if onInvoke != nil {
onInvoke()
}
if relaxRequiredForSchema {
if want, _ := c.Flags().GetBool("print-schema"); want {
c.Flags().VisitAll(func(fl *pflag.Flag) {
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
})
}
}
return nil
}
}
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips)
@@ -991,31 +908,6 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
// runShortcut is the execution pipeline for a declarative shortcut.
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
// --print-schema short-circuits everything below: it's pure local
// introspection, no identity / scope / network needed. The flag is
// only registered when the shortcut opts in via PrintFlagSchema.
if s.PrintFlagSchema != nil {
if want, _ := cmd.Flags().GetBool("print-schema"); want {
flagName, _ := cmd.Flags().GetString("flag-name")
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
if err != nil {
// PrintFlagSchema implementations return bare errors; wrap as a
// structured ExitError so --print-schema (an agent-facing
// introspection path) yields a parseable envelope, not a plain
// string.
if _, ok := err.(*output.ExitError); !ok {
err = output.Errorf(output.ExitValidation, "print_schema_error", "%s", err.Error())
}
return err
}
if len(out) == 0 {
return nil
}
fmt.Fprintln(f.IOStreams.Out, string(out))
return nil
}
}
as, err := resolveShortcutIdentity(cmd, f, s)
if err != nil {
return err
@@ -1120,16 +1012,6 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
return rctx, nil
}
// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
// head of a JSON payload makes json.Unmarshal fail with "invalid character 'ï'".
// Some editors and exporters add it silently. Only a leading BOM is removed; interior
// occurrences are left untouched.
func stripUTF8BOM(s string) string {
return strings.TrimPrefix(s, "\uFEFF")
}
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
@@ -1140,8 +1022,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
}
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
if err != nil {
return ValidationErrorf("--%s: Input is only supported for string flags", fl.Name).
WithParam("--" + fl.Name)
return FlagErrorf("--%s: Input is only supported for string flags", fl.Name)
}
if raw == "" {
continue
@@ -1150,23 +1031,17 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
// stdin: -
if raw == "-" {
if !slices.Contains(fl.Input, Stdin) {
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
WithParam("--" + fl.Name)
return FlagErrorf("--%s does not support stdin (-)", fl.Name)
}
if stdinUsed {
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
WithParam("--" + fl.Name)
return FlagErrorf("--%s: stdin (-) can only be used by one flag", fl.Name)
}
stdinUsed = true
data, err := io.ReadAll(rctx.IO().In)
if err != nil {
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
WithParam("--" + fl.Name).
WithCause(err)
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
}
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
// cell or break JSON parsing downstream.
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
rctx.Cmd.Flags().Set(fl.Name, string(data))
continue
}
@@ -1179,23 +1054,17 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
// file: @path
if strings.HasPrefix(raw, "@") {
if !slices.Contains(fl.Input, File) {
return ValidationErrorf("--%s does not support file input (@path)", fl.Name).
WithParam("--" + fl.Name)
return FlagErrorf("--%s does not support file input (@path)", fl.Name)
}
path := strings.TrimSpace(raw[1:])
if path == "" {
return ValidationErrorf("--%s: file path cannot be empty after @", fl.Name).
WithParam("--" + fl.Name)
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
}
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
return ValidationErrorf("--%s: %v", fl.Name, err).
WithParam("--" + fl.Name).
WithCause(err)
return FlagErrorf("--%s: %v", fl.Name, err)
}
// strip a leading UTF-8 BOM so it
// can't corrupt the first CSV cell or break JSON parsing downstream.
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
rctx.Cmd.Flags().Set(fl.Name, string(data))
continue
}
}
@@ -1219,8 +1088,7 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
}
}
if !valid {
return ValidationErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", ")).
WithParam("--" + fl.Name)
return FlagErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", "))
}
}
return nil
@@ -1228,8 +1096,7 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
if s.DryRun == nil {
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
WithParam("--dry-run")
return FlagErrorf("--dry-run is not supported for %s %s", s.Service, s.Command)
}
fmt.Fprintln(f.IOStreams.ErrOut, "=== Dry Run ===")
dryResult := s.DryRun(rctx.ctx, rctx)
@@ -1282,10 +1149,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
var d int
fmt.Sscanf(fl.Default, "%d", &d)
cmd.Flags().Int(fl.Name, d, desc)
case "float64":
var d float64
fmt.Sscanf(fl.Default, "%g", &d)
cmd.Flags().Float64(fl.Name, d, desc)
case "string_array":
cmd.Flags().StringArray(fl.Name, nil, desc)
case "string_slice":
@@ -1320,17 +1183,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
if s.PrintFlagSchema != nil {
// Guard against a shortcut that already declares these reserved
// introspection flags: pflag panics on a duplicate registration.
// Mirrors the Lookup guard on --format above.
if cmd.Flags().Lookup("print-schema") == nil {
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
}
if cmd.Flags().Lookup("flag-name") == nil {
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
}
}
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
}

View File

@@ -97,46 +97,6 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
}
}
// TestShortcutMount_ReservedIntrospectionFlagCollision verifies the reserved
// --print-schema / --flag-name flags are registered defensively: a shortcut
// that already declares same-named flags must not trigger pflag's duplicate-
// registration panic (the Lookup guard in registerShortcutFlagsWithContext).
func TestShortcutMount_ReservedIntrospectionFlagCollision(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "docs",
Command: "+introspect",
Description: "x",
// The shortcut's own flags collide with the names the runner auto-
// injects when PrintFlagSchema is set. Without the guard, pflag panics.
Flags: []Flag{
{Name: "print-schema", Desc: "user-defined collision"},
{Name: "flag-name", Desc: "user-defined collision"},
},
PrintFlagSchema: func(string) ([]byte, error) { return nil, nil },
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("Mount panicked on a reserved-flag name collision (Lookup guard missing?): %v", r)
}
}()
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+introspect"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if cmd.Flags().Lookup("print-schema") == nil {
t.Error("print-schema flag should still exist after the guarded registration")
}
if cmd.Flags().Lookup("flag-name") == nil {
t.Error("flag-name flag should still exist after the guarded registration")
}
}
func TestShortcutMount_JsonFlag_AcceptedWhenHasFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}

View File

@@ -129,7 +129,6 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
if err == nil {
t.Fatal("expected error for stdin not supported")
}
assertValidationParam(t, err, "--data")
if !strings.Contains(err.Error(), "does not support stdin") {
t.Errorf("unexpected error: %v", err)
}
@@ -143,7 +142,6 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
if err == nil {
t.Fatal("expected error for file not supported")
}
assertValidationParam(t, err, "--data")
if !strings.Contains(err.Error(), "does not support file input") {
t.Errorf("unexpected error: %v", err)
}
@@ -160,7 +158,6 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
if err == nil {
t.Fatal("expected error for missing file")
}
assertValidationParam(t, err, "--markdown")
if !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("unexpected error: %v", err)
}
@@ -174,7 +171,6 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty file path")
}
assertValidationParam(t, err, "--markdown")
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("unexpected error: %v", err)
}
@@ -216,58 +212,7 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
if err == nil {
t.Fatal("expected error for duplicate stdin usage")
}
assertValidationParam(t, err, "--b")
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
t.Errorf("unexpected error: %v", err)
}
}
func TestStripUTF8BOM(t *testing.T) {
cases := []struct{ name, in, want string }{
{"leading BOM removed", "\uFEFFhello", "hello"},
{"no BOM unchanged", "hello", "hello"},
{"empty unchanged", "", ""},
{"only BOM becomes empty", "\uFEFF", ""},
{"interior BOM preserved", "a\uFEFFb", "a\uFEFFb"},
{"only the first BOM removed", "\uFEFF\uFEFFx", "\uFEFFx"},
}
for _, c := range cases {
if got := stripUTF8BOM(c.in); got != c.want {
t.Errorf("%s: stripUTF8BOM(%q) = %q, want %q", c.name, c.in, got, c.want)
}
}
}
func TestResolveInputFlags_StripBOMStdin(t *testing.T) {
// A CSV piped via stdin with a leading BOM (e.g. from an upstream export)
// must reach the shortcut without the BOM, so it can't corrupt the first cell.
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "-"}, "\uFEFFname,age\nzhang,8")
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("csv"); got != "name,age\nzhang,8" {
t.Errorf("leading BOM not stripped from stdin, got %q", got)
}
}
func TestResolveInputFlags_StripBOMFile(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
// A JSON operations file saved with a BOM would otherwise fail json.Unmarshal
// with "invalid character 'ï'".
if err := os.WriteFile("ops.json", []byte("\uFEFF[{\"shortcut\":\"+cells-set\"}]"), 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"operations": "@ops.json"}, "")
flags := []Flag{{Name: "operations", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("operations"); got != "[{\"shortcut\":\"+cells-set\"}]" {
t.Errorf("leading BOM not stripped from file, got %q", got)
}
}

View File

@@ -1,22 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import "testing"
func TestValidateEnumFlags_ReturnsTypedValidation(t *testing.T) {
rctx := newTestRuntime(map[string]string{"mode": "delete"})
err := validateEnumFlags(rctx, []Flag{
{Name: "mode", Enum: []string{"append", "overwrite"}},
})
assertValidationParam(t, err, "--mode")
}
func TestHandleShortcutDryRunUnsupported_ReturnsTypedValidation(t *testing.T) {
err := handleShortcutDryRun(nil, nil, &Shortcut{
Service: "doc",
Command: "fetch",
})
assertValidationParam(t, err, "--dry-run")
}

View File

@@ -18,7 +18,7 @@ const (
// Flag describes a CLI flag for a shortcut.
type Flag struct {
Name string // flag name (e.g. "calendar-id")
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice"
Default string // default value as string
Desc string // help text
Hidden bool // hidden from --help, still readable at runtime
@@ -58,29 +58,6 @@ type Shortcut struct {
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
// OnInvoke, when non-nil, runs from the command's cobra PreRunE — before
// cobra validates required flags — so its side effect fires even when the
// call later fails on a missing required flag (which short-circuits before
// Validate/Execute). The backward-compat aliases use it to record a
// deprecation notice that must surface regardless of whether the call
// validates. Fire-and-forget: no args, no return (e.g. deprecation.SetPending).
OnInvoke func()
// PrintFlagSchema, when non-nil, opts this shortcut into the
// `--print-schema --flag-name <name>` runtime introspection contract.
// The framework auto-injects those two system flags and short-circuits
// Validate/Execute when --print-schema is set, dispatching to this hook.
//
// Contract:
// - flagName == "" → list the flags this shortcut can describe
// (output is impl-defined; agents read this to
// discover which flags are introspectable).
// - flagName == "...": → return the JSON Schema (or schema-like blob)
// for that flag.
// Return value is written to stdout verbatim; callers typically format
// it as JSON. Returning an error surfaces as a normal command error.
PrintFlagSchema func(flagName string) ([]byte, error)
// PostMount is an optional hook called after the cobra.Command is fully
// configured (flags registered, tips set) and after parent.AddCommand(cmd)
// has attached it to the parent. Use it to install custom help functions or

View File

@@ -4,7 +4,6 @@
package common
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
@@ -14,32 +13,9 @@ import (
// open_id, removes duplicates case-insensitively while preserving the
// first-occurrence form, and returns nil for an empty input. flagName is
// used in error messages to point the user at the offending CLI flag.
//
// Deprecated: use ResolveOpenIDsTyped for typed error envelopes.
func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
out, msg := resolveOpenIDs(flagName, ids, runtime)
if msg != "" {
return nil, output.ErrValidation("%s", msg)
}
return out, nil
}
// ResolveOpenIDsTyped expands the special identifier "me" to the current
// user's open_id, removes duplicates case-insensitively while preserving the
// first-occurrence form, and returns nil for an empty input. flagName names
// the flag being resolved (e.g. "--user-ids") and is recorded on the typed
// error.
func ResolveOpenIDsTyped(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
out, msg := resolveOpenIDs(flagName, ids, runtime)
if msg != "" {
return nil, ValidationErrorf("%s", msg).WithParam(flagName)
}
return out, nil
}
func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, string) {
if len(ids) == 0 {
return nil, ""
return nil, nil
}
currentUserID := runtime.UserOpenId()
seen := make(map[string]struct{}, len(ids))
@@ -47,7 +23,7 @@ func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]s
for _, id := range ids {
if strings.EqualFold(id, "me") {
if currentUserID == "" {
return nil, fmt.Sprintf("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
return nil, output.ErrValidation("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
}
id = currentUserID
}
@@ -58,5 +34,5 @@ func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]s
seen[key] = struct{}{}
out = append(out, id)
}
return out, ""
return out, nil
}

View File

@@ -75,24 +75,3 @@ func TestResolveOpenIDs_DedupIsCaseInsensitive(t *testing.T) {
t.Fatalf("case-insensitive dedup failed: got %v, want [ou_abc123]", out)
}
}
func TestResolveOpenIDsTyped_MeWithoutLogin_ReturnsTypedValidation(t *testing.T) {
rt := resolveOpenIDsTestRuntime("")
_, err := ResolveOpenIDsTyped("--user-ids", []string{"me"}, rt)
validationErr := assertValidationParam(t, err, "--user-ids")
if !strings.Contains(validationErr.Message, "--user-ids") {
t.Fatalf("error should mention the offending flag name; got: %v", err)
}
}
func TestResolveOpenIDsTyped_ExpandsMeAndDedups(t *testing.T) {
rt := resolveOpenIDsTestRuntime("ou_self")
out, err := ResolveOpenIDsTyped("--user-ids", []string{"me", "ou_a", "me", "ou_a"}, rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"ou_self", "ou_a"}
if len(out) != len(want) || out[0] != want[0] || out[1] != want[1] {
t.Fatalf("got %v, want %v", out, want)
}
}

View File

@@ -8,26 +8,16 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
// FlagErrorf returns a validation error with flag context (exit code 2).
//
// Deprecated: use ValidationErrorf for typed error envelopes.
func FlagErrorf(format string, args ...any) error {
return output.ErrValidation(format, args...)
}
// ValidationErrorf returns a typed validation error with invalid_argument subtype.
func ValidationErrorf(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
// MutuallyExclusive checks that at most one of the given flags is set.
//
// Deprecated: use MutuallyExclusiveTyped for typed error envelopes.
func MutuallyExclusive(rt *RuntimeContext, flags ...string) error {
var set []string
for _, f := range flags {
@@ -42,25 +32,7 @@ func MutuallyExclusive(rt *RuntimeContext, flags ...string) error {
return nil
}
// MutuallyExclusiveTyped checks that at most one of the given flags is set.
func MutuallyExclusiveTyped(rt *RuntimeContext, flags ...string) error {
var set []string
for _, f := range flags {
val := rt.Str(f)
if val != "" {
set = append(set, "--"+f)
}
}
if len(set) > 1 {
return ValidationErrorf("%s are mutually exclusive", strings.Join(set, " and ")).
WithParams(invalidParams(set, "mutually exclusive")...)
}
return nil
}
// AtLeastOne checks that at least one of the given flags is set.
//
// Deprecated: use AtLeastOneTyped for typed error envelopes.
func AtLeastOne(rt *RuntimeContext, flags ...string) error {
for _, f := range flags {
if rt.Str(f) != "" {
@@ -74,24 +46,7 @@ func AtLeastOne(rt *RuntimeContext, flags ...string) error {
return FlagErrorf("specify at least one of %s", strings.Join(names, " or "))
}
// AtLeastOneTyped checks that at least one of the given flags is set.
func AtLeastOneTyped(rt *RuntimeContext, flags ...string) error {
for _, f := range flags {
if rt.Str(f) != "" {
return nil
}
}
names := make([]string, len(flags))
for i, f := range flags {
names[i] = "--" + f
}
return ValidationErrorf("specify at least one of %s", strings.Join(names, " or ")).
WithParams(invalidParams(names, "required; specify at least one")...)
}
// ExactlyOne checks that exactly one of the given flags is set.
//
// Deprecated: use ExactlyOneTyped for typed error envelopes.
func ExactlyOne(rt *RuntimeContext, flags ...string) error {
if err := AtLeastOne(rt, flags...); err != nil {
return err
@@ -99,18 +54,8 @@ func ExactlyOne(rt *RuntimeContext, flags ...string) error {
return MutuallyExclusive(rt, flags...)
}
// ExactlyOneTyped checks that exactly one of the given flags is set.
func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
if err := AtLeastOneTyped(rt, flags...); err != nil {
return err
}
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 == "" {
@@ -126,25 +71,6 @@ func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, m
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) {
s := rt.Str(flagName)
param := "--" + flagName
if s == "" {
return defaultVal, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, ValidationErrorf("invalid --%s %q: must be an integer", flagName, s).WithParam(param)
}
if n < minVal || n > maxVal {
return 0, ValidationErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal).
WithParam(param)
}
return n, nil
}
// ParseIntBounded parses an int flag and clamps it to [min, max].
func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
v := rt.Int(name)
@@ -161,26 +87,13 @@ func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
// working directory. It catches traversal, symlink escape, and control
// characters by delegating to FileIO.ResolvePath. Works for both file and
// directory paths.
//
// Deprecated: use ValidateSafePathTyped for typed error envelopes.
func ValidateSafePath(fio fileio.FileIO, path string) error {
_, err := fio.ResolvePath(path)
return err
}
// ValidateSafePathTyped ensures path resolves within the current working directory.
func ValidateSafePathTyped(fio fileio.FileIO, path string) error {
_, err := fio.ResolvePath(path)
if err != nil {
return ValidationErrorf("%s", err).WithCause(err)
}
return nil
}
// RejectDangerousChars returns an error if value contains ASCII control
// characters or dangerous Unicode code points.
//
// Deprecated: use RejectDangerousCharsTyped for typed error envelopes.
func RejectDangerousChars(paramName, value string) error {
for _, r := range value {
if r < 0x20 && r != '\t' && r != '\n' {
@@ -195,31 +108,3 @@ func RejectDangerousChars(paramName, value string) error {
}
return nil
}
// RejectDangerousCharsTyped returns an error if value contains ASCII control
// characters or dangerous Unicode code points.
func RejectDangerousCharsTyped(paramName, value string) error {
for _, r := range value {
if r < 0x20 && r != '\t' && r != '\n' {
return ValidationErrorf("parameter %q contains control character U+%04X", paramName, r).
WithParam(paramName)
}
if r == 0x7F {
return ValidationErrorf("parameter %q contains DEL character", paramName).
WithParam(paramName)
}
if IsDangerousUnicode(r) {
return ValidationErrorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r).
WithParam(paramName)
}
}
return nil
}
func invalidParams(names []string, reason string) []errs.InvalidParam {
params := make([]errs.InvalidParam, len(names))
for i, name := range names {
params[i] = errs.InvalidParam{Name: name, Reason: reason}
}
return params
}

View File

@@ -11,31 +11,10 @@ import (
// ValidateChatID checks if a chat ID has valid format (oc_ prefix).
// Also extracts token from URL if provided.
//
// Deprecated: use ValidateChatIDTyped for typed error envelopes.
func ValidateChatID(input string) (string, error) {
chatID, msg := normalizeChatID(input)
if msg != "" {
return "", output.ErrValidation("%s", msg)
}
return chatID, nil
}
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
// Also extracts token from URL if provided. param names the flag being
// validated (e.g. "--chat-ids") and is recorded on the typed error.
func ValidateChatIDTyped(param, input string) (string, error) {
chatID, msg := normalizeChatID(input)
if msg != "" {
return "", ValidationErrorf("%s", msg).WithParam(param)
}
return chatID, nil
}
func normalizeChatID(input string) (string, string) {
input = strings.TrimSpace(input)
if input == "" {
return "", "chat ID cannot be empty"
return "", output.ErrValidation("chat ID cannot be empty")
}
// Extract from URL if present
if strings.Contains(input, "feishu.cn") || strings.Contains(input, "larksuite.com") {
@@ -49,40 +28,19 @@ func normalizeChatID(input string) (string, string) {
}
}
if !strings.HasPrefix(input, "oc_") {
return "", "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)"
return "", output.ErrValidation("invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)")
}
return input, ""
return input, nil
}
// ValidateUserID checks if a user ID has valid format (ou_ prefix).
//
// Deprecated: use ValidateUserIDTyped for typed error envelopes.
func ValidateUserID(input string) (string, error) {
userID, msg := normalizeUserID(input)
if msg != "" {
return "", output.ErrValidation("%s", msg)
}
return userID, nil
}
// ValidateUserIDTyped checks if a user ID has valid format (ou_ prefix).
// param names the flag being validated (e.g. "--creator-ids") and is
// recorded on the typed error.
func ValidateUserIDTyped(param, input string) (string, error) {
userID, msg := normalizeUserID(input)
if msg != "" {
return "", ValidationErrorf("%s", msg).WithParam(param)
}
return userID, nil
}
func normalizeUserID(input string) (string, string) {
input = strings.TrimSpace(input)
if input == "" {
return "", "user ID cannot be empty"
return "", output.ErrValidation("user ID cannot be empty")
}
if !strings.HasPrefix(input, "ou_") {
return "", "invalid user ID format, should start with 'ou_' (e.g., ou_abc123)"
return "", output.ErrValidation("invalid user ID format, should start with 'ou_' (e.g., ou_abc123)")
}
return input, ""
return input, nil
}

View File

@@ -4,14 +4,10 @@
package common
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/spf13/cobra"
)
@@ -30,24 +26,6 @@ func newTestRuntime(flags map[string]string) *RuntimeContext {
return &RuntimeContext{Cmd: cmd}
}
func assertValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatal("expected validation error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if param != "" && validationErr.Param != param {
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
}
return validationErr
}
func TestMutuallyExclusive(t *testing.T) {
tests := []struct {
name string
@@ -91,109 +69,6 @@ func TestMutuallyExclusive(t *testing.T) {
}
}
func TestValidationErrorf_ReturnsTypedInvalidArgument(t *testing.T) {
err := ValidationErrorf("bad %s", "flag")
validationErr := assertValidationParam(t, err, "")
if validationErr.Message != "bad flag" {
t.Fatalf("Message = %q, want %q", validationErr.Message, "bad flag")
}
}
func TestTypedFlagGroupHelpers_ReturnValidationParams(t *testing.T) {
t.Run("mutually exclusive", func(t *testing.T) {
rt := newTestRuntime(map[string]string{"a": "x", "b": "y"})
validationErr := assertValidationParam(t, MutuallyExclusiveTyped(rt, "a", "b"), "")
if len(validationErr.Params) != 2 {
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
}
if validationErr.Params[0].Name != "--a" || validationErr.Params[1].Name != "--b" {
t.Fatalf("Params names = %+v, want --a/--b", validationErr.Params)
}
})
t.Run("at least one", func(t *testing.T) {
rt := newTestRuntime(map[string]string{"a": "", "b": ""})
validationErr := assertValidationParam(t, AtLeastOneTyped(rt, "a", "b"), "")
if len(validationErr.Params) != 2 {
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
}
if !strings.Contains(validationErr.Message, "--a or --b") {
t.Fatalf("Message = %q, want flag group", validationErr.Message)
}
})
t.Run("exactly one", func(t *testing.T) {
rt := newTestRuntime(map[string]string{"a": "x", "b": "y"})
validationErr := assertValidationParam(t, ExactlyOneTyped(rt, "a", "b"), "")
if len(validationErr.Params) != 2 {
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
}
})
}
func TestValidatePageSizeTyped_ReturnsTypedValidation(t *testing.T) {
rt := newTestRuntime(map[string]string{"page-size": "nope"})
_, err := ValidatePageSizeTyped(rt, "page-size", 10, 1, 20)
assertValidationParam(t, err, "--page-size")
rt = newTestRuntime(map[string]string{"page-size": "30"})
_, err = ValidatePageSizeTyped(rt, "page-size", 10, 1, 20)
assertValidationParam(t, err, "--page-size")
}
func TestValidateIDTyped_ReturnsTypedValidation(t *testing.T) {
chatID, err := ValidateChatIDTyped("--chat-ids", "https://example.feishu.cn/foo/oc_abc")
if err != nil {
t.Fatalf("ValidateChatIDTyped valid URL: %v", err)
}
if chatID != "oc_abc" {
t.Fatalf("chatID = %q, want oc_abc", chatID)
}
assertValidationParam(t, func() error {
_, err := ValidateChatIDTyped("--chat-ids", "bad")
return err
}(), "--chat-ids")
assertValidationParam(t, func() error {
_, err := ValidateUserIDTyped("--creator-ids", "bad")
return err
}(), "--creator-ids")
}
func TestRejectDangerousCharsTyped_ReturnsTypedValidation(t *testing.T) {
err := RejectDangerousCharsTyped("--query", "bad\x01")
validationErr := assertValidationParam(t, err, "--query")
if !strings.Contains(validationErr.Message, "control character") {
t.Fatalf("Message = %q, want control character", validationErr.Message)
}
}
func TestWrapInputStatErrorTyped_ReturnsTypedValidation(t *testing.T) {
cause := &fileio.PathValidationError{Err: errors.New("outside cwd")}
err := WrapInputStatErrorTyped(cause)
validationErr := assertValidationParam(t, err, "")
if !strings.Contains(validationErr.Message, "unsafe file path") {
t.Fatalf("Message = %q, want unsafe file path", validationErr.Message)
}
if !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("expected errors.Is(fileio.ErrPathValidation) to match")
}
}
func TestWrapSaveErrorTyped_ClassifiesPathAndFileIO(t *testing.T) {
pathErr := &fileio.PathValidationError{Err: errors.New("outside cwd")}
assertValidationParam(t, WrapSaveErrorTyped(pathErr), "")
mkdirErr := &fileio.MkdirError{Err: errors.New("permission denied")}
err := WrapSaveErrorTyped(mkdirErr)
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if internalErr.Subtype != errs.SubtypeFileIO {
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
}
}
func TestAtLeastOne(t *testing.T) {
tests := []struct {
name string
@@ -371,20 +246,3 @@ func TestValidateSafePath_AllowsNonExistentPath(t *testing.T) {
t.Fatalf("expected no error for non-existent path, got: %v", err)
}
}
// TestValidateSafePathTyped_ReturnsTypedValidation verifies that an escaping
// path is rejected with a typed validation error and a safe path passes.
func TestValidateSafePathTyped_ReturnsTypedValidation(t *testing.T) {
outside := t.TempDir()
workDir := t.TempDir()
chdirForTest(t, workDir)
if err := os.Symlink(outside, filepath.Join(workDir, "evil_out")); err != nil {
t.Fatalf("Symlink: %v", err)
}
assertValidationParam(t, ValidateSafePathTyped(&localfileio.LocalFileIO{}, "evil_out"), "")
if err := ValidateSafePathTyped(&localfileio.LocalFileIO{}, "new_output_dir"); err != nil {
t.Fatalf("expected no error for safe path, got: %v", err)
}
}

View File

@@ -1,122 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var DriveCover = common.Shortcut{
Service: "drive",
Command: "+cover",
Description: "List or download stable cover presets for a Drive file",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "Drive file token", Required: true},
{Name: "spec", Desc: "cover preset: default | icon | grid | small | middle | big | square"},
{Name: "version", Desc: "optional file version"},
{Name: "list-only", Type: "bool", Desc: "list built-in cover specs without downloading"},
{Name: "output", Desc: "local output path for downloaded cover"},
{Name: "if-exists", Desc: "output conflict policy: error | overwrite | rename", Default: drivePreviewIfExistsError, Enum: []string{drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if err := validateDrivePreviewMode(runtime.Str("spec"), runtime.Bool("list-only"), runtime.Str("output"), "spec"); err != nil {
return err
}
if err := validateDrivePreviewIfExists(runtime.Str("if-exists")); err != nil {
return err
}
if spec := strings.TrimSpace(runtime.Str("spec")); spec != "" {
if _, ok := findDriveCoverSpec(spec); !ok {
return wrapDriveCoverUnavailable(spec)
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fileToken := runtime.Str("file-token")
if runtime.Bool("list-only") {
return common.NewDryRunAPI().
Desc("List built-in cover specs (no API call)").
Set("mode", "list").
Set("file_token", fileToken).
Set("candidates", buildDriveCoverListOutput(fileToken)["candidates"])
}
spec, _ := findDriveCoverSpec(runtime.Str("spec"))
params := buildDriveCoverDownloadParams(strings.TrimSpace(runtime.Str("version")), spec)
dry := common.NewDryRunAPI().
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("Download selected cover preset directly via preview_download").
Params(params).
Set("file_token", fileToken).
Set("selected_spec", spec.Name).
Set("output", runtime.Str("output"))
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := runtime.Str("file-token")
version := strings.TrimSpace(runtime.Str("version"))
requestedSpec := strings.TrimSpace(runtime.Str("spec"))
outputPath := runtime.Str("output")
ifExists := runtime.Str("if-exists")
if runtime.Bool("list-only") {
runtime.Out(buildDriveCoverListOutput(fileToken), nil)
return nil
}
spec, ok := findDriveCoverSpec(requestedSpec)
if !ok {
return wrapDriveCoverUnavailable(requestedSpec)
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading cover %s for file %s\n", spec.Name, common.MaskToken(fileToken))
result, err := downloadDrivePreviewArtifactWithParams(ctx, runtime, fileToken, buildDriveCoverDownloadParams(version, spec), outputPath, ifExists, spec.FallbackExt)
if err != nil {
return wrapDriveCoverDownloadError(err, spec.Name)
}
result["mode"] = "download"
result["file_token"] = fileToken
result["selected_spec"] = spec.Name
runtime.Out(result, nil)
return nil
},
}
// wrapDriveCoverDownloadError reclassifies preview_download HTTP 404 responses
// on the +cover path as a failed precondition on --spec, because the Drive
// shortcut contract documents 404 as "this file has no artifact for that cover
// preset" rather than a transient transport failure.
func wrapDriveCoverDownloadError(err error, requestedSpec string) error {
if err == nil {
return nil
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Code != http.StatusNotFound {
return err
}
hint := fmt.Sprintf(
"This may mean no artifact exists for --spec %q, or that the file token/version is invalid. Verify the inputs, or rerun with `lark-cli drive +cover --file-token <file-token> --list-only`. Available cover specs: %s",
requestedSpec,
strings.Join(availableDriveCoverSpecs(), ", "),
)
return errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"preview_download returned HTTP 404 for --spec %q",
requestedSpec,
).WithParam("--spec").WithCode(problem.Code).WithLogID(problem.LogID).WithHint(hint).WithCause(err)
}

View File

@@ -1,118 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var DrivePreview = common.Shortcut{
Service: "drive",
Command: "+preview",
Description: "List or download available preview artifacts for a Drive file",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "Drive file token", Required: true},
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source"},
{Name: "version", Desc: "optional file version"},
{Name: "list-only", Type: "bool", Desc: "list preview candidates without downloading"},
{Name: "output", Desc: "local output path for downloaded preview"},
{Name: "if-exists", Desc: "output conflict policy: error | overwrite | rename", Default: drivePreviewIfExistsError, Enum: []string{drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if err := validateDrivePreviewMode(runtime.Str("type"), runtime.Bool("list-only"), runtime.Str("output"), "type"); err != nil {
return err
}
return validateDrivePreviewIfExists(runtime.Str("if-exists"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fileToken := runtime.Str("file-token")
version := strings.TrimSpace(runtime.Str("version"))
body := map[string]interface{}{}
if version != "" {
body["version"] = version
}
dry := common.NewDryRunAPI().
POST("/open-apis/drive/v1/medias/:file_token/preview_result").
Desc("[1] Fetch preview candidates for a Drive file").
Set("file_token", fileToken)
if len(body) > 0 {
dry.Body(body)
}
if runtime.Bool("list-only") {
return dry.Set("mode", "list")
}
downloadParams := map[string]interface{}{
"preview_type": "<selected type_code from preview_result>",
}
if version != "" {
downloadParams["version"] = version
} else {
downloadParams["version"] = "<resolved version from preview_result>"
}
return dry.
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[2] Download the requested preview after selecting a matching candidate from preview_result").
Params(downloadParams).
Set("mode", "download").
Set("requested_type", runtime.Str("type")).
Set("output", runtime.Str("output"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := runtime.Str("file-token")
version := strings.TrimSpace(runtime.Str("version"))
requestedType := strings.TrimSpace(runtime.Str("type"))
outputPath := runtime.Str("output")
ifExists := runtime.Str("if-exists")
body := map[string]interface{}{}
if version != "" {
body["version"] = version
}
fmt.Fprintf(runtime.IO().ErrOut, "Fetching preview candidates: %s\n", common.MaskToken(fileToken))
data, candidates, err := fetchDrivePreviewCandidates(runtime, fileToken, body)
if err != nil {
return err
}
if runtime.Bool("list-only") {
runtime.Out(buildDrivePreviewListOutput(fileToken, candidates), nil)
return nil
}
candidate, ok := selectDrivePreviewCandidate(candidates, requestedType)
if !ok {
return wrapDrivePreviewUnavailable(fileToken, requestedType, candidates, "")
}
if !candidate.Downloadable {
return wrapDrivePreviewNotReady(fileToken, requestedType, candidate)
}
downloadVersion := version
if downloadVersion == "" {
downloadVersion = versionString(data["version"])
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading preview %s for file %s\n", candidate.Type, common.MaskToken(fileToken))
result, err := downloadDrivePreviewArtifact(ctx, runtime, fileToken, candidate.TypeCode, downloadVersion, outputPath, ifExists, drivePreviewFallbackExt(candidate.Type))
if err != nil {
return err
}
result["mode"] = "download"
result["file_token"] = fileToken
result["selected_type"] = candidate.Type
runtime.Out(result, nil)
return nil
},
}

View File

@@ -1,813 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"errors"
"fmt"
"io/fs"
"mime"
"net/http"
"path/filepath"
"slices"
"strconv"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
drivePreviewIfExistsError = "error"
drivePreviewIfExistsOverwrite = "overwrite"
drivePreviewIfExistsRename = "rename"
)
type drivePreviewCandidate struct {
Type string
TypeCode string
TypeName string
Label string
Status string
StatusCode string
Downloadable bool
Reason string
}
type driveCoverSpec struct {
Name string
Label string
Description string
PreviewType string
BusType string
Platform string
Width int
Height int
Policy string
FallbackExt string
}
type driveExtensionResolution struct {
Ext string
Source string
Detail string
}
type drivePreviewTypeMeta struct {
Code string
Name string
Type string
Label string
Aliases []string
}
type drivePreviewStatusMeta struct {
Code string
Name string
Reason string
Downloadable bool
}
var drivePreviewMimeToExt = map[string]string{
"application/json": ".json",
"application/msword": ".doc",
"application/pdf": ".pdf",
"application/xml": ".xml",
"application/zip": ".zip",
"image/bmp": ".bmp",
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/svg+xml": ".svg",
"image/webp": ".webp",
"text/csv": ".csv",
"text/html": ".html",
"text/plain": ".txt",
"text/xml": ".xml",
"video/mp4": ".mp4",
"application/octet-stream": "",
}
var drivePreviewTypes = []drivePreviewTypeMeta{
{Code: "0", Name: "PDF", Type: "pdf", Label: "PDF Preview"},
{Code: "1", Name: "PNG", Type: "png", Label: "PNG Preview", Aliases: []string{"image"}},
{Code: "2", Name: "PAGES", Type: "pages", Label: "Paged Preview"},
{Code: "3", Name: "VIDEO", Type: "video", Label: "Video Preview"},
{Code: "4", Name: "MP4_360P", Type: "mp4_360p", Label: "MP4 360P Preview"},
{Code: "5", Name: "MP4_480P", Type: "mp4_480p", Label: "MP4 480P Preview"},
{Code: "6", Name: "MP4_720P", Type: "mp4_720p", Label: "MP4 720P Preview"},
{Code: "7", Name: "JPG", Type: "jpg", Label: "JPG Preview", Aliases: []string{"image"}},
{Code: "8", Name: "HTML", Type: "html", Label: "HTML Preview"},
{Code: "9", Name: "PDF_LIN", Type: "pdf_lin", Label: "Linearized PDF Preview"},
{Code: "10", Name: "XOD", Type: "xod", Label: "XOD Preview"},
{Code: "11", Name: "JPG_LIN", Type: "jpg_lin", Label: "Linearized JPG Preview", Aliases: []string{"image"}},
{Code: "12", Name: "PNG_LIN", Type: "png_lin", Label: "Linearized PNG Preview", Aliases: []string{"image"}},
{Code: "13", Name: "ARCHIVE", Type: "archive", Label: "Archive Preview"},
{Code: "14", Name: "TEXT", Type: "text", Label: "Text Preview"},
{Code: "15", Name: "PDF_PART", Type: "pdf_part", Label: "Partial PDF Preview"},
{Code: "16", Name: "SOURCE_FILE", Type: "source_file", Label: "Source File", Aliases: []string{"source"}},
{Code: "17", Name: "VIDEO_META", Type: "video_meta", Label: "Video Metadata"},
{Code: "18", Name: "WPS", Type: "wps", Label: "WPS Preview"},
{Code: "19", Name: "SPLIT_PNG", Type: "split_png", Label: "Split PNG Preview", Aliases: []string{"image"}},
{Code: "20", Name: "MEDIA_RESULT", Type: "media_result", Label: "Media Result"},
{Code: "21", Name: "MIME", Type: "mime", Label: "MIME Type"},
{Code: "22", Name: "SPILT_IMG_TXT", Type: "spilt_img_txt", Label: "Split Image Text"},
{Code: "23", Name: "MP4_1080P", Type: "mp4_1080p", Label: "MP4 1080P Preview"},
{Code: "24", Name: "IMAGE_META", Type: "image_meta", Label: "Image Metadata"},
{Code: "25", Name: "DOC_PART", Type: "doc_part", Label: "Document Part"},
{Code: "26", Name: "WATERMARK_PDF", Type: "watermark_pdf", Label: "Watermarked PDF Preview"},
{Code: "27", Name: "FILE_WATERMARK", Type: "file_watermark", Label: "File Watermark"},
}
var drivePreviewStatuses = []drivePreviewStatusMeta{
{Code: "0", Name: "READY", Downloadable: true},
{Code: "1", Name: "PROCESSING", Reason: "Preview is still processing."},
{Code: "2", Name: "FAILED", Reason: "Preview generation failed."},
{Code: "3", Name: "FAILED_NOT_RETRY", Reason: "Preview generation failed and will not retry."},
{Code: "4", Name: "INVALID_EXTENTION", Reason: "File extension is invalid for this preview type."},
{Code: "5", Name: "FILE_TOO_LARGE", Reason: "File is too large for preview generation."},
{Code: "6", Name: "EMPTY_FILE", Reason: "File is empty."},
{Code: "7", Name: "NO_SUPPORT", Reason: "Preview is not supported for this file."},
{Code: "8", Name: "INVALID_PREVIEW_TYPE", Reason: "Preview type is invalid."},
{Code: "9", Name: "NEED_PASSWORD", Reason: "Preview requires a password."},
{Code: "10", Name: "FILE_INVALID", Reason: "File is invalid."},
{Code: "11", Name: "TOO_MANY_PAGES", Reason: "File has too many pages for preview."},
{Code: "1001", Name: "ARCHIVE_INVALID_FORMAT", Reason: "Archive format is invalid."},
{Code: "1002", Name: "ARCHIVE_TOO_MANY_NODES", Reason: "Archive contains too many nodes."},
{Code: "1003", Name: "ARCHIVE_TOO_MANY_NODES_PER_DIR", Reason: "Archive directory contains too many nodes."},
{Code: "1004", Name: "THIRD_ENC_NO_PERMISSION", Reason: "No permission for third-party encrypted file."},
{Code: "1006", Name: "NOT_SUPPORT_DECRYPT_THIRD_ENC_FILE", Reason: "Third-party encrypted file cannot be decrypted for preview."},
}
var drivePreviewTypeByCode = func() map[string]drivePreviewTypeMeta {
out := make(map[string]drivePreviewTypeMeta, len(drivePreviewTypes))
for _, meta := range drivePreviewTypes {
out[meta.Code] = meta
}
return out
}()
var drivePreviewStatusByCode = func() map[string]drivePreviewStatusMeta {
out := make(map[string]drivePreviewStatusMeta, len(drivePreviewStatuses))
for _, meta := range drivePreviewStatuses {
out[meta.Code] = meta
}
return out
}()
var driveCoverSpecs = []driveCoverSpec{
{
Name: "default",
Label: "Default Cover",
Description: "Standard large cover (1280x1280).",
PreviewType: "1",
BusType: "cover",
Platform: "pc",
FallbackExt: ".png",
},
{
Name: "icon",
Label: "Icon",
Description: "Small list icon (120x120).",
PreviewType: "1",
BusType: "icon",
FallbackExt: ".png",
},
{
Name: "grid",
Label: "Grid Cover",
Description: "Grid/card stream cover (360x360).",
PreviewType: "1",
BusType: "grid",
FallbackExt: ".png",
},
{
Name: "small",
Label: "Small Graph",
Description: "PC small graph cover (480x480).",
PreviewType: "1",
BusType: "small_graph",
Platform: "pc",
FallbackExt: ".png",
},
{
Name: "middle",
Label: "Middle Cover",
Description: "Medium-sized cover (720x720).",
PreviewType: "1",
BusType: "middle",
FallbackExt: ".png",
},
{
Name: "big",
Label: "Big Cover",
Description: "Large mobile-oriented cover (850x850).",
PreviewType: "1",
BusType: "big",
Platform: "mobile",
FallbackExt: ".png",
},
{
Name: "square",
Label: "Square Cover",
Description: "Square-cropped grid cover (360x360).",
PreviewType: "1",
Width: 360,
Height: 360,
Policy: "near",
FallbackExt: ".png",
},
}
// validateDrivePreviewMode checks the required flag combinations for list and
// download modes.
func validateDrivePreviewMode(selected string, listOnly bool, outputPath, flagName string) error {
selected = strings.TrimSpace(selected)
outputPath = strings.TrimSpace(outputPath)
selectedFlag := "--" + flagName
if listOnly {
if selected != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be combined with --list-only", selectedFlag).WithParam(selectedFlag)
}
if outputPath != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be combined with --list-only").WithParam("--output")
}
return nil
}
if selected == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "either --list-only or %s is required", selectedFlag).WithParam(selectedFlag)
}
if outputPath == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output is required when %s is set", selectedFlag).WithParam("--output")
}
return nil
}
// validateDrivePreviewIfExists validates the accepted overwrite policy values.
func validateDrivePreviewIfExists(policy string) error {
switch strings.TrimSpace(policy) {
case "", drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename:
return nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --if-exists %q: allowed values are error, overwrite, rename", policy).WithParam("--if-exists")
}
}
// fetchDrivePreviewCandidates loads preview_result data and normalizes the
// returned candidate list.
func fetchDrivePreviewCandidates(runtime *common.RuntimeContext, fileToken string, body map[string]interface{}) (map[string]interface{}, []drivePreviewCandidate, error) {
data, err := runtime.CallAPITyped(
"POST",
fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_result", validate.EncodePathSegment(fileToken)),
nil,
body,
)
if err != nil {
return nil, nil, err
}
return data, normalizeDrivePreviewCandidates(data), nil
}
// normalizeDrivePreviewCandidates converts preview_result items into internal
// candidate records with stable type and status metadata.
func normalizeDrivePreviewCandidates(data map[string]interface{}) []drivePreviewCandidate {
items := common.GetSlice(data, "preview_results")
candidates := make([]drivePreviewCandidate, 0, len(items))
for _, item := range items {
raw, ok := item.(map[string]interface{})
if !ok {
continue
}
typeCode := firstString(raw, "preview_type", "type_code", "type")
statusCode := firstString(raw, "preview_status", "status_code", "status")
candidate := drivePreviewCandidate{
TypeCode: typeCode,
StatusCode: statusCode,
Reason: strings.TrimSpace(firstString(raw, "reason", "status_msg", "message", "msg", "detail")),
}
applyDrivePreviewTypeMeta(&candidate)
applyDrivePreviewStatusMeta(&candidate)
candidates = append(candidates, candidate)
}
return candidates
}
// selectDrivePreviewCandidate matches a requested preview type or alias against
// the available candidates.
func selectDrivePreviewCandidate(candidates []drivePreviewCandidate, requested string) (drivePreviewCandidate, bool) {
requested = normalizeDrivePreviewRequest(requested)
if requested == "" {
return drivePreviewCandidate{}, false
}
for _, candidate := range candidates {
if requested == candidate.Type || requested == strings.ToLower(candidate.TypeName) || requested == strings.ToLower(strings.TrimSpace(candidate.TypeCode)) {
return candidate, true
}
}
var firstAliasMatch drivePreviewCandidate
hasAliasMatch := false
for _, candidate := range candidates {
if !slices.Contains(previewAliasesForCandidate(candidate), requested) {
continue
}
if candidate.Downloadable {
return candidate, true
}
if !hasAliasMatch {
firstAliasMatch = candidate
hasAliasMatch = true
}
}
if hasAliasMatch {
return firstAliasMatch, true
}
return drivePreviewCandidate{}, false
}
// buildDrivePreviewListOutput formats preview candidates for --list-only
// responses.
func buildDrivePreviewListOutput(fileToken string, candidates []drivePreviewCandidate) map[string]interface{} {
items := make([]map[string]interface{}, 0, len(candidates))
for _, candidate := range candidates {
item := map[string]interface{}{
"type": candidate.Type,
"type_code": candidate.TypeCode,
"label": candidate.Label,
"status": candidate.Status,
"status_code": candidate.StatusCode,
"downloadable": candidate.Downloadable,
}
if candidate.Reason != "" {
item["reason"] = candidate.Reason
}
items = append(items, item)
}
out := map[string]interface{}{
"mode": "list",
"file_token": fileToken,
"candidates": items,
}
if len(items) > 0 {
out["next_action"] = "select one candidate and rerun with --type plus --output"
}
return out
}
// buildDriveCoverListOutput formats the built-in cover specs for --list-only
// responses.
func buildDriveCoverListOutput(fileToken string) map[string]interface{} {
items := make([]map[string]interface{}, 0, len(driveCoverSpecs))
for _, spec := range driveCoverSpecs {
item := map[string]interface{}{
"spec": spec.Name,
"label": spec.Label,
}
if spec.Description != "" {
item["description"] = spec.Description
}
items = append(items, item)
}
return map[string]interface{}{
"mode": "list",
"file_token": fileToken,
"candidates": items,
"next_action": "select one spec and rerun with --spec plus --output",
}
}
// findDriveCoverSpec resolves a cover spec by its user-facing name.
func findDriveCoverSpec(name string) (driveCoverSpec, bool) {
name = strings.ToLower(strings.TrimSpace(name))
for _, spec := range driveCoverSpecs {
if spec.Name == name {
return spec, true
}
}
return driveCoverSpec{}, false
}
// buildDriveCoverDownloadParams translates a cover spec into preview_download
// query parameters.
func buildDriveCoverDownloadParams(version string, spec driveCoverSpec) map[string]interface{} {
params := map[string]interface{}{
"preview_type": spec.PreviewType,
}
if strings.TrimSpace(spec.BusType) != "" {
params["bus_type"] = spec.BusType
}
if strings.TrimSpace(spec.Platform) != "" {
params["platform"] = spec.Platform
}
if spec.Width > 0 {
params["width"] = spec.Width
}
if spec.Height > 0 {
params["height"] = spec.Height
}
if strings.TrimSpace(spec.Policy) != "" {
params["policy"] = spec.Policy
}
if strings.TrimSpace(version) != "" {
params["version"] = version
}
return params
}
// downloadDrivePreviewArtifact downloads a preview artifact for a single
// preview_type value.
func downloadDrivePreviewArtifact(ctx context.Context, runtime *common.RuntimeContext, fileToken, previewType, version, outputPath, ifExists, fallbackExt string) (map[string]interface{}, error) {
query := map[string]interface{}{
"preview_type": previewType,
}
if strings.TrimSpace(version) != "" {
query["version"] = version
}
return downloadDrivePreviewArtifactWithParams(ctx, runtime, fileToken, query, outputPath, ifExists, fallbackExt)
}
// downloadDrivePreviewArtifactWithParams downloads a preview artifact using the
// provided preview_download query parameters and writes it to the local path.
func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common.RuntimeContext, fileToken string, query map[string]interface{}, outputPath, ifExists, fallbackExt string) (map[string]interface{}, error) {
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
queryParams := make(larkcore.QueryParams, len(query))
for key, value := range query {
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" {
continue
}
queryParams[key] = []string{text}
}
apiReq := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
QueryParams: queryParams,
}
resp, err := runtime.DoAPIStream(ctx, apiReq)
if err != nil {
return nil, wrapDriveNetworkErr(err, "preview download failed: %s", err)
}
defer resp.Body.Close()
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists)
if err != nil {
return nil, err
}
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return nil, driveSaveError(err)
}
savedPath, _ := runtime.ResolveSavePath(finalPath)
if savedPath == "" {
savedPath = finalPath
}
return map[string]interface{}{
"output_path": savedPath,
"size_bytes": result.Size(),
"content_type": resp.Header.Get("Content-Type"),
"status": "READY",
}, nil
}
// resolveDrivePreviewOutputPath finalizes the save path, applying extension
// inference and the selected collision policy.
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists string) (string, *driveExtensionResolution, error) {
finalPath, resolution := autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
switch ifExists {
case "", drivePreviewIfExistsError:
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --if-exists overwrite or rename)", finalPath).WithParam("--output")
} else if !errors.Is(statErr, fs.ErrNotExist) {
return "", nil, errs.NewInternalError(errs.SubtypeFileIO, "cannot access output path %s: %s", finalPath, statErr).WithCause(statErr)
}
return finalPath, resolution, nil
case drivePreviewIfExistsOverwrite:
return finalPath, resolution, nil
case drivePreviewIfExistsRename:
renamed, err := nextAvailableDrivePreviewPath(runtime.FileIO(), finalPath)
if err != nil {
return "", nil, err
}
if _, err := runtime.ResolveSavePath(renamed); err != nil {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
return renamed, resolution, nil
default:
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --if-exists %q: allowed values are error, overwrite, rename", ifExists).WithParam("--if-exists")
}
}
// nextAvailableDrivePreviewPath finds the first unused "name (n)" variant for a
// target output path.
func nextAvailableDrivePreviewPath(fio fileio.FileIO, path string) (string, error) {
if _, err := fio.Stat(path); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return path, nil
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot access output path %s: %s", path, err).WithCause(err)
}
dir := filepath.Dir(path)
ext := filepath.Ext(path)
base := strings.TrimSuffix(filepath.Base(path), ext)
for i := 1; i < 10000; i++ {
candidate := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext))
if _, err := fio.Stat(candidate); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return candidate, nil
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot access candidate output path %s: %s", candidate, err).WithCause(err)
}
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot allocate a unique output path for %s", path)
}
// autoAppendDrivePreviewExtension appends an inferred extension when the user
// did not provide one explicitly.
func autoAppendDrivePreviewExtension(outputPath string, header http.Header, fallbackExt string) (string, *driveExtensionResolution) {
if drivePreviewHasExplicitExtension(outputPath) {
return outputPath, nil
}
normalizedPath := outputPath
if filepath.Ext(outputPath) == "." {
normalizedPath = strings.TrimSuffix(outputPath, ".")
}
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if fallbackExt != "" {
return normalizedPath + fallbackExt, &driveExtensionResolution{
Ext: fallbackExt,
Source: "fallback",
Detail: "default fallback",
}
}
return outputPath, nil
}
// drivePreviewHasExplicitExtension reports whether the path already ends with a
// usable filename extension.
func drivePreviewHasExplicitExtension(path string) bool {
ext := filepath.Ext(path)
return ext != "" && ext != "."
}
// drivePreviewExtensionByContentType maps a response Content-Type header to a
// file extension when possible.
func drivePreviewExtensionByContentType(contentType string) *driveExtensionResolution {
if contentType == "" {
return nil
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
}
if ext, ok := drivePreviewMimeToExt[strings.ToLower(mediaType)]; ok && ext != "" {
return &driveExtensionResolution{
Ext: ext,
Source: "Content-Type",
Detail: contentType,
}
}
return nil
}
// drivePreviewExtensionByContentDisposition extracts an extension from the
// response filename metadata.
func drivePreviewExtensionByContentDisposition(header http.Header) *driveExtensionResolution {
filename := strings.TrimSpace(larkcore.FileNameByHeader(header))
if filename == "" {
return nil
}
ext := filepath.Ext(filename)
if ext == "" || ext == "." {
return nil
}
return &driveExtensionResolution{
Ext: ext,
Source: "Content-Disposition",
Detail: filename,
}
}
// drivePreviewFallbackExt returns the default extension for known preview type
// aliases when headers do not provide one.
func drivePreviewFallbackExt(alias string) string {
switch normalizeDrivePreviewRequest(alias) {
case "pdf":
return ".pdf"
case "html":
return ".html"
case "text":
return ".txt"
case "png", "png_lin", "split_png":
return ".png"
case "jpg", "jpg_lin":
return ".jpg"
case "source", "source_file":
return ""
default:
return ""
}
}
// applyDrivePreviewTypeMeta fills normalized type metadata from the preview
// type code.
func applyDrivePreviewTypeMeta(candidate *drivePreviewCandidate) {
if candidate == nil {
return
}
if meta, ok := drivePreviewTypeByCode[candidate.TypeCode]; ok {
candidate.Type = meta.Type
candidate.TypeName = meta.Name
candidate.Label = meta.Label
return
}
code := strings.TrimSpace(candidate.TypeCode)
if code == "" {
candidate.Type = "unknown"
candidate.TypeName = "UNKNOWN"
candidate.Label = "Unknown Preview Type"
return
}
candidate.Type = "unknown_" + code
candidate.TypeName = "UNKNOWN"
candidate.Label = fmt.Sprintf("Unknown Preview Type %s", code)
}
// applyDrivePreviewStatusMeta fills normalized status metadata from the preview
// status code.
func applyDrivePreviewStatusMeta(candidate *drivePreviewCandidate) {
if candidate == nil {
return
}
if meta, ok := drivePreviewStatusByCode[candidate.StatusCode]; ok {
candidate.Status = meta.Name
candidate.Downloadable = meta.Downloadable
if candidate.Reason == "" && !meta.Downloadable {
candidate.Reason = meta.Reason
}
if meta.Downloadable {
candidate.Reason = ""
}
return
}
candidate.Status = "UNKNOWN"
candidate.Downloadable = false
if candidate.Reason == "" {
if strings.TrimSpace(candidate.StatusCode) == "" {
candidate.Reason = "Preview status is missing."
} else {
candidate.Reason = fmt.Sprintf("Unknown preview status %s.", candidate.StatusCode)
}
}
}
// normalizeDrivePreviewRequest canonicalizes user input for preview type
// matching.
func normalizeDrivePreviewRequest(requested string) string {
requested = strings.ToLower(strings.TrimSpace(requested))
requested = strings.ReplaceAll(requested, "-", "_")
requested = strings.ReplaceAll(requested, " ", "_")
return requested
}
// previewAliasesForCandidate returns configured aliases for a preview
// candidate's type code.
func previewAliasesForCandidate(candidate drivePreviewCandidate) []string {
if meta, ok := drivePreviewTypeByCode[candidate.TypeCode]; ok {
return meta.Aliases
}
return nil
}
// firstString returns the first non-empty string-like value from the provided
// keys.
func firstString(m map[string]interface{}, keys ...string) string {
for _, key := range keys {
v, ok := m[key]
if !ok || v == nil {
continue
}
switch t := v.(type) {
case string:
if strings.TrimSpace(t) != "" {
return t
}
case fmt.Stringer:
if s := strings.TrimSpace(t.String()); s != "" {
return s
}
case float64:
return strconv.FormatInt(int64(t), 10)
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case bool:
return strconv.FormatBool(t)
}
}
return ""
}
// versionString normalizes version fields from heterogeneous API payload types.
func versionString(v interface{}) string {
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case float64:
return strconv.FormatInt(int64(t), 10)
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
default:
return ""
}
}
// availableDrivePreviewTypes lists unique normalized preview type names from
// the candidate set.
func availableDrivePreviewTypes(candidates []drivePreviewCandidate) []string {
seen := map[string]bool{}
out := make([]string, 0, len(candidates))
for _, candidate := range candidates {
name := strings.TrimSpace(candidate.Type)
if name == "" || seen[name] {
continue
}
seen[name] = true
out = append(out, name)
}
return out
}
// availableDriveCoverSpecs lists the supported built-in cover spec names.
func availableDriveCoverSpecs() []string {
out := make([]string, 0, len(driveCoverSpecs))
for _, spec := range driveCoverSpecs {
out = append(out, spec.Name)
}
return out
}
// wrapDrivePreviewUnavailable builds a validation error for an unsupported
// preview selection.
func wrapDrivePreviewUnavailable(fileToken, requested string, candidates []drivePreviewCandidate, reason string) error {
available := availableDrivePreviewTypes(candidates)
if reason == "" {
reason = fmt.Sprintf("requested preview type %q is not available for file %s", requested, fileToken)
}
hint := "rerun with --list-only to inspect available preview types"
if len(available) > 0 {
hint = fmt.Sprintf("available preview types: %s", strings.Join(available, ", "))
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
}
// wrapDrivePreviewNotReady builds an actionable error for a preview candidate
// that exists but is not yet downloadable.
func wrapDrivePreviewNotReady(fileToken, requested string, candidate drivePreviewCandidate) error {
reason := candidate.Reason
if reason == "" {
reason = fmt.Sprintf("preview type %q is not downloadable yet (status=%s)", requested, candidate.Status)
}
hint := fmt.Sprintf("rerun `lark-cli drive +preview --file-token %s --list-only` to inspect current candidate status", fileToken)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
}
// wrapDriveCoverUnavailable builds a validation error for an unknown cover
// spec.
func wrapDriveCoverUnavailable(requested string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --spec %q", requested).
WithHint("available cover specs: %s", strings.Join(availableDriveCoverSpecs(), ", ")).
WithParam("--spec")
}

View File

@@ -1,926 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// TestDrivePreviewListOnlyNormalizesCandidates verifies list mode output is
// normalized from preview_result payloads.
func TestDrivePreviewListOnlyNormalizesCandidates(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"preview_results": []map[string]interface{}{
{"preview_type": 0, "preview_status": 0},
{"preview_type": 14, "preview_status": 1},
{"preview_type": 16, "preview_status": 7},
},
},
},
})
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--list-only",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["mode"]; got != "list" {
t.Fatalf("mode=%v, want list", got)
}
candidates, _ := data["candidates"].([]interface{})
if len(candidates) != 3 {
t.Fatalf("len(candidates)=%d, want 3", len(candidates))
}
first, _ := candidates[0].(map[string]interface{})
if got := first["type"]; got != "pdf" {
t.Fatalf("candidate[0].type=%v, want pdf", got)
}
if got := first["type_code"]; got != "0" {
t.Fatalf("candidate[0].type_code=%v, want 0", got)
}
if got := first["status"]; got != "READY" {
t.Fatalf("candidate[0].status=%v, want READY", got)
}
if got := first["downloadable"]; got != true {
t.Fatalf("candidate[0].downloadable=%v, want true", got)
}
second, _ := candidates[1].(map[string]interface{})
if got := second["status_code"]; got != "1" {
t.Fatalf("candidate[1].status_code=%v, want 1", got)
}
if got := second["reason"]; got != "Preview is still processing." {
t.Fatalf("candidate[1].reason=%v, want processing reason", got)
}
}
// TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy verifies preview
// downloads use the resolved type and rename collision handling.
func TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"version": 7,
"preview_results": []map[string]interface{}{
{"preview_type": 0, "preview_status": 0},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/file_preview/preview_download?preview_type=0",
Status: 200,
Body: []byte("%PDF-1.7"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile(filepath.Join(tmpDir, "report.pdf"), []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--type", "pdf",
"--output", "report",
"--if-exists", "rename",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["selected_type"]; got != "pdf" {
t.Fatalf("selected_type=%v, want pdf", got)
}
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
if err != nil {
t.Fatalf("EvalSymlinks() error: %v", err)
}
wantPath := filepath.Join(resolvedTmpDir, "report (1).pdf")
if got := data["output_path"]; got != wantPath {
t.Fatalf("output_path=%v, want %s", got, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected preview artifact at %q: %v", wantPath, err)
}
}
// TestDrivePreviewRejectsUnavailableType verifies unavailable preview types
// return an actionable validation error.
func TestDrivePreviewRejectsUnavailableType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"preview_results": []map[string]interface{}{
{"preview_type": 8, "preview_status": 0},
},
},
},
})
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--type", "pdf",
"--output", "report",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected unavailable type error, got nil")
}
if !strings.Contains(err.Error(), `requested preview type "pdf" is not available`) {
t.Fatalf("unexpected error: %v", err)
}
}
// TestSelectDrivePreviewCandidatePrefersDownloadableAliasMatch verifies alias
// selection prefers a downloadable candidate over an earlier unavailable one.
func TestSelectDrivePreviewCandidatePrefersDownloadableAliasMatch(t *testing.T) {
candidate, ok := selectDrivePreviewCandidate([]drivePreviewCandidate{
{Type: "png", TypeCode: "1", Downloadable: false, Status: "PROCESSING"},
{Type: "jpg", TypeCode: "7", Downloadable: true, Status: "READY"},
}, "image")
if !ok {
t.Fatal("expected alias match, got none")
}
if candidate.Type != "jpg" {
t.Fatalf("selected candidate=%q, want jpg", candidate.Type)
}
if !candidate.Downloadable {
t.Fatalf("selected candidate should be downloadable: %+v", candidate)
}
}
// TestDriveCoverListOnlyUsesStaticSpecs verifies cover list mode returns the
// built-in spec catalog without calling APIs.
func TestDriveCoverListOnlyUsesStaticSpecs(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--list-only",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
candidates, _ := data["candidates"].([]interface{})
if len(candidates) != len(driveCoverSpecs) {
t.Fatalf("len(candidates)=%d, want %d", len(candidates), len(driveCoverSpecs))
}
last, _ := candidates[len(candidates)-1].(map[string]interface{})
if got := last["spec"]; got != "square" {
t.Fatalf("last spec=%v, want square", got)
}
}
// TestDriveCoverDownloadUsesMappedCoverOptionAndPreviewType verifies cover
// downloads send the expected preview_download query mapping.
func TestDriveCoverDownloadUsesMappedCoverOptionAndPreviewType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
var capturedQuery url.Values
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/file_cover/preview_download",
Status: 200,
Body: []byte("png-data"),
Headers: http.Header{
"Content-Type": []string{"image/png"},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.Query()
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--spec", "square",
"--output", "cover",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["selected_spec"]; got != "square" {
t.Fatalf("selected_spec=%v, want square", got)
}
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
if err != nil {
t.Fatalf("EvalSymlinks() error: %v", err)
}
wantPath := filepath.Join(resolvedTmpDir, "cover.png")
if got := data["output_path"]; got != wantPath {
t.Fatalf("output_path=%v, want %s", got, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected cover file at %q: %v", wantPath, err)
}
if got := capturedQuery.Get("preview_type"); got != "1" {
t.Fatalf("preview_type=%q, want 1", got)
}
if got := capturedQuery.Get("bus_type"); got != "" {
t.Fatalf("bus_type=%q, want empty for square crop flow", got)
}
if got := capturedQuery.Get("platform"); got != "" {
t.Fatalf("platform=%q, want empty when using default platform", got)
}
if got := capturedQuery.Get("width"); got != "360" {
t.Fatalf("width=%q, want 360", got)
}
if got := capturedQuery.Get("height"); got != "360" {
t.Fatalf("height=%q, want 360", got)
}
if got := capturedQuery.Get("policy"); got != "near" {
t.Fatalf("policy=%q, want near", got)
}
}
// TestDriveCoverDownload404ReturnsFailedPrecondition verifies the +cover path
// reclassifies preview_download HTTP 404 as a non-retryable spec/state issue.
func TestDriveCoverDownload404ReturnsFailedPrecondition(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/file_cover/preview_download",
Status: http.StatusNotFound,
Body: []byte(`{"code":404,"msg":"no artifact"}`),
Headers: http.Header{
"Content-Type": []string{"application/json"},
},
})
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--spec", "square",
"--output", "cover",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected cover 404 error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype=%q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition)
}
if validationErr.Param != "--spec" {
t.Fatalf("param=%q, want --spec", validationErr.Param)
}
if validationErr.Code != http.StatusNotFound {
t.Fatalf("code=%d, want %d", validationErr.Code, http.StatusNotFound)
}
if !strings.Contains(validationErr.Hint, "--list-only") {
t.Fatalf("hint=%q, want --list-only guidance", validationErr.Hint)
}
if !strings.Contains(validationErr.Hint, "file token/version is invalid") {
t.Fatalf("hint=%q, want invalid file token/version guidance", validationErr.Hint)
}
if !strings.Contains(validationErr.Hint, "available cover specs") && !strings.Contains(validationErr.Hint, "default, icon, grid") {
t.Fatalf("hint=%q, want available cover specs guidance", validationErr.Hint)
}
if !strings.Contains(validationErr.Error(), `preview_download returned HTTP 404 for --spec "square"`) {
t.Fatalf("message=%q, want neutral 404 message", validationErr.Error())
}
}
// newDrivePreviewRuntime builds a shortcut runtime with preconfigured preview
// and cover flags for DryRun and helper tests.
func newDrivePreviewRuntime(t *testing.T, use string, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: use}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("spec", "", "")
cmd.Flags().String("version", "", "")
cmd.Flags().String("output", "", "")
cmd.Flags().String("if-exists", drivePreviewIfExistsError, "")
cmd.Flags().Bool("list-only", false, "")
for name, value := range stringFlags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
for name, value := range boolFlags {
if !value {
continue
}
if err := cmd.Flags().Set(name, "true"); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return common.TestNewRuntimeContextWithCtx(context.Background(), cmd, driveTestConfig())
}
// decodeDryRunOutput marshals a DryRunAPI helper into a generic map for test
// assertions.
func decodeDryRunOutput(t *testing.T, dry *common.DryRunAPI) map[string]interface{} {
t.Helper()
raw, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
return out
}
// TestDrivePreviewDryRunIncludesVersionAndMode verifies preview DryRun records
// versioned request metadata in download mode.
func TestDrivePreviewDryRunIncludesVersionAndMode(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_preview",
"type": "image",
"version": "7",
"output": "preview",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
if got := data["mode"]; got != "download" {
t.Fatalf("mode=%v, want download", got)
}
if got := data["requested_type"]; got != "image" {
t.Fatalf("requested_type=%v, want image", got)
}
api, _ := data["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("len(api)=%d, want 2", len(api))
}
call, _ := api[0].(map[string]interface{})
if got := call["method"]; got != "POST" {
t.Fatalf("method=%v, want POST", got)
}
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_preview/preview_result" {
t.Fatalf("url=%v, want preview_result", got)
}
body, _ := call["body"].(map[string]interface{})
if got := body["version"]; got != "7" {
t.Fatalf("body.version=%v, want 7", got)
}
downloadCall, _ := api[1].(map[string]interface{})
if got := downloadCall["method"]; got != "GET" {
t.Fatalf("download method=%v, want GET", got)
}
if got := downloadCall["url"]; got != "/open-apis/drive/v1/medias/file_preview/preview_download" {
t.Fatalf("download url=%v, want preview_download", got)
}
params, _ := downloadCall["params"].(map[string]interface{})
if got := params["preview_type"]; got != "<selected type_code from preview_result>" {
t.Fatalf("download params.preview_type=%v, want placeholder", got)
}
if got := params["version"]; got != "7" {
t.Fatalf("download params.version=%v, want 7", got)
}
}
// TestDrivePreviewDryRunListOmitsBodyWithoutVersion verifies list-mode DryRun
// omits the request body when no version is supplied.
func TestDrivePreviewDryRunListOmitsBodyWithoutVersion(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_preview",
}, map[string]bool{"list-only": true})
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
if got := data["mode"]; got != "list" {
t.Fatalf("mode=%v, want list", got)
}
api, _ := data["api"].([]interface{})
call, _ := api[0].(map[string]interface{})
if _, ok := call["body"]; ok {
t.Fatalf("dry-run body should be omitted when version is empty: %#v", call)
}
}
// TestDrivePreviewDryRunDownloadWithoutVersionShowsResolvedVersion verifies
// download-mode DryRun documents the second preview_download step even when the
// final version is only known after preview_result resolves candidates.
func TestDrivePreviewDryRunDownloadWithoutVersionShowsResolvedVersion(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_preview",
"type": "pdf",
"output": "preview",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
api, _ := data["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("len(api)=%d, want 2", len(api))
}
downloadCall, _ := api[1].(map[string]interface{})
params, _ := downloadCall["params"].(map[string]interface{})
if got := params["version"]; got != "<resolved version from preview_result>" {
t.Fatalf("download params.version=%v, want resolved-version placeholder", got)
}
}
// TestDriveCoverDryRunListAndDownload verifies cover DryRun output for both
// list and download modes.
func TestDriveCoverDryRunListAndDownload(t *testing.T) {
listRuntime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
"file-token": "file_cover",
}, map[string]bool{"list-only": true})
listData := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), listRuntime))
if got := listData["mode"]; got != "list" {
t.Fatalf("list mode=%v, want list", got)
}
if _, ok := listData["candidates"].([]interface{}); !ok {
t.Fatalf("list candidates missing: %#v", listData)
}
downloadRuntime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
"file-token": "file_cover",
"spec": "square",
"version": "3",
"output": "cover",
}, nil)
downloadData := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), downloadRuntime))
if got := downloadData["selected_spec"]; got != "square" {
t.Fatalf("selected_spec=%v, want square", got)
}
api, _ := downloadData["api"].([]interface{})
call, _ := api[0].(map[string]interface{})
params, _ := call["params"].(map[string]interface{})
if got := params["width"]; got != float64(360) {
t.Fatalf("params.width=%v, want 360", got)
}
if got := params["policy"]; got != "near" {
t.Fatalf("params.policy=%v, want near", got)
}
}
// TestDriveCoverDryRunDefaultSpecIncludesVersionAndPlatform verifies DryRun
// params include version and built-in platform metadata for default covers.
func TestDriveCoverDryRunDefaultSpecIncludesVersionAndPlatform(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
"file-token": "file_cover",
"spec": "default",
"version": "5",
"output": "cover",
}, nil)
data := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), runtime))
api, _ := data["api"].([]interface{})
call, _ := api[0].(map[string]interface{})
params, _ := call["params"].(map[string]interface{})
if got := params["bus_type"]; got != "cover" {
t.Fatalf("params.bus_type=%v, want cover", got)
}
if got := params["platform"]; got != "pc" {
t.Fatalf("params.platform=%v, want pc", got)
}
if got := params["version"]; got != "5" {
t.Fatalf("params.version=%v, want 5", got)
}
}
// TestDrivePreviewValidationErrors verifies preview flag validation rejects
// incomplete and conflicting argument combinations.
func TestDrivePreviewValidationErrors(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "either --list-only or --type is required") {
t.Fatalf("unexpected missing type error: %v", err)
}
err = mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--list-only",
"--type", "pdf",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "--type cannot be combined with --list-only") {
t.Fatalf("unexpected list-only conflict: %v", err)
}
err = mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--type", "pdf",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "--output is required when --type is set") {
t.Fatalf("unexpected missing output error: %v", err)
}
}
// TestDrivePreviewNotReadyReturnsFailedPrecondition verifies a known but
// unready preview candidate returns a failed-precondition error.
func TestDrivePreviewNotReadyReturnsFailedPrecondition(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"preview_results": []map[string]interface{}{
{"preview_type": 1, "preview_status": 1},
},
},
},
})
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--type", "image",
"--output", "preview",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected not-ready error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype=%q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition)
}
if validationErr.Param != "--type" {
t.Fatalf("param=%q, want --type", validationErr.Param)
}
if !strings.Contains(validationErr.Hint, "--list-only") {
t.Fatalf("hint=%q, want list-only guidance", validationErr.Hint)
}
}
// TestDriveCoverRejectsUnknownSpec verifies unsupported cover specs produce a
// validation error with available alternatives.
func TestDriveCoverRejectsUnknownSpec(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--spec", "poster",
"--output", "cover",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected invalid spec error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--spec" {
t.Fatalf("param=%q, want --spec", validationErr.Param)
}
if !strings.Contains(validationErr.Hint, "available cover specs") {
t.Fatalf("hint=%q, want available specs", validationErr.Hint)
}
}
// TestDriveCoverValidationErrors verifies cover flag validation rejects
// incomplete and conflicting argument combinations.
func TestDriveCoverValidationErrors(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--spec", "default",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "--output is required when --spec is set") {
t.Fatalf("unexpected missing output error: %v", err)
}
err = mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--list-only",
"--spec", "default",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "--spec cannot be combined with --list-only") {
t.Fatalf("unexpected list-only conflict: %v", err)
}
}
// TestDrivePreviewCommonHelpers exercises helper branches for extension
// inference and fallback extension mapping.
func TestDrivePreviewCommonHelpers(t *testing.T) {
if got := drivePreviewFallbackExt("pdf"); got != ".pdf" {
t.Fatalf("fallbackExt(pdf)=%q, want .pdf", got)
}
if got := drivePreviewFallbackExt("html"); got != ".html" {
t.Fatalf("fallbackExt(html)=%q, want .html", got)
}
if got := drivePreviewFallbackExt("text"); got != ".txt" {
t.Fatalf("fallbackExt(text)=%q, want .txt", got)
}
if got := drivePreviewFallbackExt("jpg"); got != ".jpg" {
t.Fatalf("fallbackExt(jpg)=%q, want .jpg", got)
}
if got := drivePreviewFallbackExt("jpg_lin"); got != ".jpg" {
t.Fatalf("fallbackExt(jpg_lin)=%q, want .jpg", got)
}
if got := drivePreviewFallbackExt("split_png"); got != ".png" {
t.Fatalf("fallbackExt(split_png)=%q, want .png", got)
}
if got := drivePreviewFallbackExt("source"); got != "" {
t.Fatalf("fallbackExt(source)=%q, want empty", got)
}
if got := drivePreviewFallbackExt("unknown"); got != "" {
t.Fatalf("fallbackExt(unknown)=%q, want empty", got)
}
specs := availableDriveCoverSpecs()
if len(specs) == 0 || specs[len(specs)-1] != "square" {
t.Fatalf("availableDriveCoverSpecs()=%v, want square included", specs)
}
header := http.Header{}
header.Set("Content-Disposition", `attachment; filename="preview.pdf"`)
resolution := drivePreviewExtensionByContentDisposition(header)
if resolution == nil || resolution.Ext != ".pdf" {
t.Fatalf("content disposition resolution=%+v, want .pdf", resolution)
}
header.Set("Content-Disposition", `attachment; filename="preview"`)
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
t.Fatalf("content disposition without ext should be nil: %+v", resolution)
}
path, fallback := autoAppendDrivePreviewExtension("cover", http.Header{}, ".png")
if path != "cover.png" || fallback == nil || fallback.Source != "fallback" {
t.Fatalf("fallback append = (%q, %+v), want cover.png with fallback source", path, fallback)
}
path, fallback = autoAppendDrivePreviewExtension("cover.", http.Header{}, ".png")
if path != "cover.png" || fallback == nil {
t.Fatalf("trailing-dot append = (%q, %+v), want cover.png", path, fallback)
}
path, fallback = autoAppendDrivePreviewExtension("cover.pdf", http.Header{}, ".png")
if path != "cover.pdf" || fallback != nil {
t.Fatalf("explicit ext append = (%q, %+v), want unchanged path", path, fallback)
}
}
// TestDrivePreviewMetadataAndPathResolution verifies metadata normalization
// and output path resolution helpers across rename and overwrite flows.
func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
candidate := drivePreviewCandidate{TypeCode: "999", StatusCode: "", Reason: ""}
applyDrivePreviewTypeMeta(&candidate)
applyDrivePreviewStatusMeta(&candidate)
if candidate.Type != "unknown_999" {
t.Fatalf("candidate.Type=%q, want unknown_999", candidate.Type)
}
if candidate.Reason != "Preview status is missing." {
t.Fatalf("candidate.Reason=%q, want missing-status reason", candidate.Reason)
}
ready := drivePreviewCandidate{TypeCode: "1", StatusCode: "0"}
applyDrivePreviewTypeMeta(&ready)
applyDrivePreviewStatusMeta(&ready)
if ready.Type != "png" || !ready.Downloadable {
t.Fatalf("ready candidate=%+v, want downloadable png", ready)
}
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile(filepath.Join(tmpDir, "preview.pdf"), []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
runtime := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
header := http.Header{}
header.Set("Content-Type", "application/pdf")
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename)
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(rename) error: %v", err)
}
if !strings.HasSuffix(renamed, "preview (1).pdf") {
t.Fatalf("renamed=%q, want preview (1).pdf suffix", renamed)
}
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep")
if err == nil {
t.Fatal("expected invalid if-exists error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--if-exists" {
t.Fatalf("param=%q, want --if-exists", validationErr.Param)
}
unusedPath, err := nextAvailableDrivePreviewPath(runtime.FileIO(), "fresh.pdf")
if err != nil {
t.Fatalf("nextAvailableDrivePreviewPath(unused) error: %v", err)
}
if unusedPath != "fresh.pdf" {
t.Fatalf("unusedPath=%q, want fresh.pdf", unusedPath)
}
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite)
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(overwrite) error: %v", err)
}
if !strings.HasSuffix(overwritten, "preview.pdf") {
t.Fatalf("overwritten=%q, want preview.pdf suffix", overwritten)
}
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
f.FileIOProvider = &statErrorProvider{inner: f.FileIOProvider, err: fs.ErrPermission}
runtimeWithStatErr := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
runtimeWithStatErr.Factory = f
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError)
if err == nil {
t.Fatal("expected stat permission error, got nil")
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if internalErr.Subtype != errs.SubtypeFileIO {
t.Fatalf("Subtype=%q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
}
}
type drivePreviewTestStringer string
type statErrorProvider struct {
inner fileio.Provider
err error
}
func (p *statErrorProvider) Name() string { return "stat-error" }
func (p *statErrorProvider) ResolveFileIO(ctx context.Context) fileio.FileIO {
return &statErrorFileIO{inner: p.inner.ResolveFileIO(ctx), err: p.err}
}
type statErrorFileIO struct {
inner fileio.FileIO
err error
}
func (f *statErrorFileIO) Open(name string) (fileio.File, error) { return f.inner.Open(name) }
func (f *statErrorFileIO) Stat(string) (fileio.FileInfo, error) { return nil, f.err }
func (f *statErrorFileIO) ResolvePath(path string) (string, error) { return f.inner.ResolvePath(path) }
func (f *statErrorFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) {
return f.inner.Save(path, opts, body)
}
// String implements fmt.Stringer for scalar helper tests.
func (s drivePreviewTestStringer) String() string { return string(s) }
// TestDrivePreviewScalarHelpers verifies scalar coercion helpers normalize
// mixed API field types into strings.
func TestDrivePreviewScalarHelpers(t *testing.T) {
got := firstString(map[string]interface{}{
"blank": " ",
"number": float64(7),
"flag": true,
"named": drivePreviewTestStringer(" named "),
"integer": int64(9),
}, "blank", "named", "number")
if got != "named" {
t.Fatalf("firstString()=%q, want named", got)
}
if got := firstString(map[string]interface{}{"flag": true}, "flag"); got != "true" {
t.Fatalf("firstString(bool)=%q, want true", got)
}
if got := firstString(map[string]interface{}{"integer": int64(9)}, "integer"); got != "9" {
t.Fatalf("firstString(int64)=%q, want 9", got)
}
if got := versionString(" 42 "); got != "42" {
t.Fatalf("versionString(string)=%q, want 42", got)
}
if got := versionString(float64(8)); got != "8" {
t.Fatalf("versionString(float64)=%q, want 8", got)
}
if got := versionString(int64(11)); got != "11" {
t.Fatalf("versionString(int64)=%q, want 11", got)
}
if got := versionString(struct{}{}); got != "" {
t.Fatalf("versionString(struct)=%q, want empty", got)
}
}
// TestDrivePreviewAliasAndAvailabilityHelpers verifies alias lookup,
// normalization, and available-type de-duplication helpers.
func TestDrivePreviewAliasAndAvailabilityHelpers(t *testing.T) {
if got := normalizeDrivePreviewRequest(" Source File "); got != "source_file" {
t.Fatalf("normalizeDrivePreviewRequest()=%q, want source_file", got)
}
aliases := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "1"})
if len(aliases) == 0 || aliases[0] != "image" {
t.Fatalf("previewAliasesForCandidate()=%v, want image alias", aliases)
}
if got := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "999"}); got != nil {
t.Fatalf("previewAliasesForCandidate(unknown)=%v, want nil", got)
}
types := availableDrivePreviewTypes([]drivePreviewCandidate{
{Type: "pdf"},
{Type: "pdf"},
{Type: " jpg "},
{Type: ""},
})
if len(types) != 2 || types[0] != "pdf" || types[1] != "jpg" {
t.Fatalf("availableDrivePreviewTypes()=%v, want [pdf jpg]", types)
}
}
// TestDrivePreviewUnavailableHintAndContentTypeFallback verifies unavailable
// preview errors and content-type fallback extension inference.
func TestDrivePreviewUnavailableHintAndContentTypeFallback(t *testing.T) {
err := wrapDrivePreviewUnavailable("file_preview", "html", []drivePreviewCandidate{
{Type: "pdf"},
{Type: "jpg"},
}, "")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(validationErr.Hint, "available preview types: pdf, jpg") {
t.Fatalf("hint=%q, want available preview types", validationErr.Hint)
}
err = wrapDrivePreviewUnavailable("file_preview", "html", nil, fmt.Sprintf("custom reason for %s", "html"))
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(validationErr.Hint, "--list-only") {
t.Fatalf("hint=%q, want list-only guidance", validationErr.Hint)
}
resolution := drivePreviewExtensionByContentType("text/plain; charset=utf-8")
if resolution == nil || resolution.Ext != ".txt" {
t.Fatalf("drivePreviewExtensionByContentType()=%+v, want .txt", resolution)
}
}

View File

@@ -354,7 +354,7 @@ func parseDriveSearchPageSize(raw string) (int, error) {
// server-side failure or empty result.
func validateDriveSearchIDs(spec driveSearchSpec) error {
for _, id := range spec.CreatorIDs {
if _, err := common.ValidateUserIDTyped("--creator-ids", id); err != nil {
if _, err := common.ValidateUserID(id); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids")
}
}
@@ -362,7 +362,7 @@ func validateDriveSearchIDs(spec driveSearchSpec) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids")
}
for _, id := range spec.ChatIDs {
if _, err := common.ValidateChatIDTyped("--chat-ids", id); err != nil {
if _, err := common.ValidateChatID(id); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids %q: %s", id, err).WithParam("--chat-ids")
}
}
@@ -370,7 +370,7 @@ func validateDriveSearchIDs(spec driveSearchSpec) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n).WithParam("--sharer-ids")
}
for _, id := range spec.SharerIDs {
if _, err := common.ValidateUserIDTyped("--sharer-ids", id); err != nil {
if _, err := common.ValidateUserID(id); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids %q: %s", id, err).WithParam("--sharer-ids")
}
}

View File

@@ -12,8 +12,6 @@ func Shortcuts() []common.Shortcut {
DriveCreateFolder,
DriveCreateShortcut,
DriveDownload,
DrivePreview,
DriveCover,
DriveAddComment,
DriveExport,
DriveExportDownload,

View File

@@ -15,8 +15,6 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+create-folder",
"+create-shortcut",
"+download",
"+preview",
"+cover",
"+version-history",
"+version-get",
"+version-revert",

View File

@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
@@ -197,12 +196,8 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
header, _ := card["header"].(cardObj)
title := ""
subtitle := ""
headerTags := ""
if header != nil {
title = c.extractHeaderTitle(header)
subtitle = c.extractHeaderSubtitle(header)
headerTags = c.extractHeaderTags(header)
}
bodyContent := ""
@@ -211,19 +206,13 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
}
var sb strings.Builder
if title != "" && subtitle != "" {
sb.WriteString(fmt.Sprintf("<card title=\"%s\" subtitle=\"%s\">\n", cardEscapeAttr(title), cardEscapeAttr(subtitle)))
} else if title != "" {
sb.WriteString(fmt.Sprintf("<card title=\"%s\">\n", cardEscapeAttr(title)))
} else if subtitle != "" {
sb.WriteString(fmt.Sprintf("<card subtitle=\"%s\">\n", cardEscapeAttr(subtitle)))
if title != "" {
sb.WriteString("<card title=\"")
sb.WriteString(cardEscapeAttr(title))
sb.WriteString("\">\n")
} else {
sb.WriteString("<card>\n")
}
if headerTags != "" {
sb.WriteString(headerTags)
sb.WriteString("\n")
}
if bodyContent != "" {
sb.WriteString(bodyContent)
sb.WriteString("\n")
@@ -244,49 +233,6 @@ func (c *cardConverter) extractHeaderTitle(header cardObj) string {
return ""
}
// extractHeaderSubtitle returns the subtitle text of a card header, supporting both
// the property-wrapped and flat element formats.
func (c *cardConverter) extractHeaderSubtitle(header cardObj) string {
if prop, ok := header["property"].(cardObj); ok {
if subtitleElem, ok := prop["subtitle"]; ok {
return c.extractTextContent(subtitleElem)
}
}
if subtitleElem, ok := header["subtitle"]; ok {
return c.extractTextContent(subtitleElem)
}
return ""
}
// extractHeaderTags returns a space-joined string of header tag labels from textTagList,
// supporting both property-wrapped and flat header formats.
func (c *cardConverter) extractHeaderTags(header cardObj) string {
var prop cardObj
if p, ok := header["property"].(cardObj); ok {
prop = p
} else {
prop = header
}
tagList, ok := prop["textTagList"].([]interface{})
if !ok || len(tagList) == 0 {
return ""
}
var tags []string
for _, tag := range tagList {
tm, ok := tag.(cardObj)
if !ok {
continue
}
if text := c.convertElement(tm, 0); text != "" {
tags = append(tags, text)
}
}
if len(tags) == 0 {
return ""
}
return strings.Join(tags, " ")
}
func (c *cardConverter) convertBody(body cardObj) string {
var elements []interface{}
@@ -533,11 +479,8 @@ func (c *cardConverter) convertDiv(prop cardObj, _ string) string {
if textElem, ok := prop["text"].(cardObj); ok {
if text := c.convertElement(textElem, 0); text != "" {
textProp := c.extractProperty(textElem)
if textStyle, ok := textProp["textStyle"].(cardObj); ok {
if size, _ := textStyle["size"].(string); size == "notation" {
text = "📝 " + text
}
if textSize, _ := textElem["text_size"].(string); textSize == "notation" {
text = "📝 " + text
}
results = append(results, text)
}
@@ -615,14 +558,7 @@ func (c *cardConverter) convertEmoji(prop cardObj) string {
}
func (c *cardConverter) convertLocalDatetime(prop cardObj) string {
var ms string
switch v := prop["milliseconds"].(type) {
case string:
ms = v
case float64:
ms = strconv.FormatInt(int64(v), 10)
}
if ms != "" {
if ms, ok := prop["milliseconds"].(string); ok && ms != "" {
if formatted := cardFormatMillisToISO8601(ms); formatted != "" {
return formatted
}
@@ -853,22 +789,22 @@ func (c *cardConverter) convertCollapsiblePanel(prop cardObj, _ string) string {
}
}
indicator := "▶"
if expanded {
indicator = "▼"
}
var sb strings.Builder
sb.WriteString(indicator + " " + title + "\n")
if elements, ok := prop["elements"].([]interface{}); ok {
content := c.convertElements(elements, 1)
for _, line := range strings.Split(content, "\n") {
if line != "" {
sb.WriteString(" " + line + "\n")
shouldExpand := expanded || c.mode == cardModeDetailed
if shouldExpand {
var sb strings.Builder
sb.WriteString("▼ " + title + "\n")
if elements, ok := prop["elements"].([]interface{}); ok {
content := c.convertElements(elements, 1)
for _, line := range strings.Split(content, "\n") {
if line != "" {
sb.WriteString(" " + line + "\n")
}
}
}
sb.WriteString("▲")
return sb.String()
}
sb.WriteString("▲")
return sb.String()
return "▶ " + title
}
func (c *cardConverter) convertInteractiveContainer(prop cardObj, id string) string {
@@ -916,17 +852,10 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string {
}
disabled, _ := prop["disabled"].(bool)
if disabled {
result := fmt.Sprintf("[%s ✗]", buttonText)
if tips, ok := prop["disabledTips"].(cardObj); ok {
if tipsText := c.extractTextContent(tips); tipsText != "" {
result += fmt.Sprintf("(tips:\"%s\")", tipsText)
}
}
return result
if disabled && c.mode == cardModeConcise {
return fmt.Sprintf("[%s ✗]", buttonText)
}
result := fmt.Sprintf("[%s]", buttonText)
if actions, ok := prop["actions"].([]interface{}); ok {
for _, action := range actions {
am, ok := action.(cardObj)
@@ -936,32 +865,24 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string {
if am["type"] == "open_url" {
if ad, ok := am["action"].(cardObj); ok {
if urlStr, ok := ad["url"].(string); ok && urlStr != "" {
result = fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr)
break
return fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr)
}
}
}
}
}
if confirmObj, ok := prop["confirm"].(cardObj); ok {
var parts []string
if titleElem, ok := confirmObj["title"]; ok {
if t := c.extractTextContent(titleElem); t != "" {
parts = append(parts, t)
if disabled && c.mode == cardModeDetailed {
result := fmt.Sprintf("[%s ✗]", buttonText)
if tips, ok := prop["disabledTips"].(cardObj); ok {
if tipsText := c.extractTextContent(tips); tipsText != "" {
result += fmt.Sprintf("(tips:\"%s\")", tipsText)
}
}
if textElem, ok := confirmObj["text"]; ok {
if t := c.extractTextContent(textElem); t != "" {
parts = append(parts, t)
}
}
if len(parts) > 0 {
result += fmt.Sprintf("(confirm:\"%s\")", strings.Join(parts, ": "))
}
return result
}
return result
return fmt.Sprintf("[%s]", buttonText)
}
func (c *cardConverter) convertActions(prop cardObj) string {
@@ -993,33 +914,11 @@ func (c *cardConverter) convertOverflow(prop cardObj) string {
if !ok {
continue
}
text := ""
if textElem, ok := om["text"].(cardObj); ok {
text = c.extractTextContent(textElem)
}
if text == "" {
continue
}
urlStr := ""
if actions, ok := om["actions"].([]interface{}); ok {
for _, a := range actions {
am, ok := a.(cardObj)
if !ok {
continue
}
if am["type"] == "open_url" {
if ad, ok := am["action"].(cardObj); ok {
urlStr, _ = ad["url"].(string)
}
}
if text := c.extractTextContent(textElem); text != "" {
optTexts = append(optTexts, text)
}
}
if urlStr != "" {
text = fmt.Sprintf("[%s](%s)", escapeMDLinkText(text), urlStr)
} else if value, _ := om["value"].(string); value != "" {
text += "(" + value + ")"
}
optTexts = append(optTexts, text)
}
return "⋮ " + strings.Join(optTexts, ", ")
}
@@ -1059,20 +958,17 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str
if !ok {
continue
}
value, _ := om["value"].(string)
optText := ""
if textElem, ok := om["text"].(cardObj); ok {
optText = c.extractTextContent(textElem)
}
if optText == "" {
optText = c.lookupOptionUserName(value)
}
if optText == "" {
optText = value
optText, _ = om["value"].(string)
}
if optText == "" {
continue
}
value, _ := om["value"].(string)
if selectedValues[value] {
optText = "✓" + optText
hasSelected = true
@@ -1093,15 +989,17 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str
}
result := "{" + strings.Join(optionTexts, " / ") + "}"
var attrs []string
if isMulti {
attrs = append(attrs, "multi")
}
if c.mode == cardModeDetailed && strings.Contains(id, "person") {
attrs = append(attrs, "type:person")
}
if len(attrs) > 0 {
result += "(" + strings.Join(attrs, " ") + ")"
if c.mode == cardModeDetailed {
var attrs []string
if isMulti {
attrs = append(attrs, "multi")
}
if strings.Contains(id, "person") {
attrs = append(attrs, "type:person")
}
if len(attrs) > 0 {
result += "(" + strings.Join(attrs, " ") + ")"
}
}
return result
}
@@ -1127,17 +1025,6 @@ func (c *cardConverter) convertSelectImg(prop cardObj, _ string) string {
}
value, _ := om["value"].(string)
text := fmt.Sprintf("🖼️ Image %d", i+1)
if value != "" {
text += "(" + value + ")"
}
if imageID, ok := om["imageID"].(string); ok && imageID != "" {
originKey, imgToken := c.getImageKeyAndToken(imageID)
if originKey != "" {
text += "(img_key:" + originKey + ")"
} else if imgToken != "" {
text += "(img_token:" + imgToken + ")"
}
}
if selectedValues[value] {
text = "✓" + text
}
@@ -1240,14 +1127,13 @@ func (c *cardConverter) convertImage(prop cardObj, _ string) string {
}
result := "🖼️ " + alt
if imageID, ok := prop["imageID"].(string); ok && imageID != "" {
originKey, imgToken := c.getImageKeyAndToken(imageID)
if originKey != "" {
result += "(img_key:" + originKey + ")"
} else if imgToken != "" {
result += "(img_token:" + imgToken + ")"
} else {
result += "(img_key:" + imageID + ")"
if c.mode == cardModeDetailed {
if imageID, ok := prop["imageID"].(string); ok && imageID != "" {
if token := c.getImageToken(imageID); token != "" {
result += "(img_token:" + token + ")"
} else {
result += "(img_key:" + imageID + ")"
}
}
}
return result
@@ -1259,25 +1145,20 @@ func (c *cardConverter) convertImgCombination(prop cardObj) string {
return ""
}
result := fmt.Sprintf("🖼️ %d image(s)", len(imgList))
var keys []string
for _, img := range imgList {
im, ok := img.(cardObj)
if !ok {
continue
}
if imageID, ok := im["imageID"].(string); ok && imageID != "" {
originKey, imgToken := c.getImageKeyAndToken(imageID)
if originKey != "" {
keys = append(keys, originKey)
} else if imgToken != "" {
keys = append(keys, imgToken)
} else {
if c.mode == cardModeDetailed {
var keys []string
for _, img := range imgList {
im, ok := img.(cardObj)
if !ok {
continue
}
if imageID, ok := im["imageID"].(string); ok && imageID != "" {
keys = append(keys, imageID)
}
}
}
if len(keys) > 0 {
result += "(keys:" + strings.Join(keys, ",") + ")"
if len(keys) > 0 {
result += "(keys:" + strings.Join(keys, ",") + ")"
}
}
return result
}
@@ -1295,11 +1176,7 @@ func (c *cardConverter) convertChart(prop cardObj, _ string) string {
if ct, ok := chartSpec["type"].(string); ok && ct != "" {
chartType = ct
if typeName, ok := cardChartTypeNames[ct]; ok {
if title != "Chart" {
title += " (" + typeName + ")"
} else {
title = typeName
}
title += typeName
}
}
}
@@ -1317,25 +1194,12 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri
if !ok {
return ""
}
// VChart spec: data is an array of series objects ([{"id":"...","values":[...]}]).
// Older/object format: data is a map with a "values" key directly.
var values []interface{}
switch d := chartSpec["data"].(type) {
case cardObj:
if v, ok := d["values"].([]interface{}); ok {
values = v
}
case []interface{}:
for _, series := range d {
if sm, ok := series.(cardObj); ok {
if v, ok := sm["values"].([]interface{}); ok {
values = append(values, v...)
}
}
}
dataObj, ok := chartSpec["data"].(cardObj)
if !ok {
return ""
}
if len(values) == 0 {
values, ok := dataObj["values"].([]interface{})
if !ok || len(values) == 0 {
return ""
}
@@ -1380,24 +1244,28 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri
func (c *cardConverter) convertAudio(prop cardObj, _ string) string {
result := "🎵 Audio"
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["audioID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
if c.mode == cardModeDetailed {
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["audioID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
}
}
return result
}
func (c *cardConverter) convertVideo(prop cardObj, _ string) string {
result := "🎬 Video"
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["videoID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
if c.mode == cardModeDetailed {
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["videoID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
}
}
return result
}
@@ -1455,14 +1323,9 @@ func (c *cardConverter) convertTable(prop cardObj) string {
func (c *cardConverter) extractTableCellValue(data interface{}) string {
switch v := data.(type) {
case string:
// Lark API serialises array-type cell data as a Go-format string like
// "[map[text:VIP] map[text:Premium]]". Detect and extract text values.
if texts := goMapArrayTexts(v); len(texts) > 0 {
return strings.Join(texts, ", ")
}
return v
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
return strconv.FormatFloat(v, 'f', 2, 64)
case []interface{}:
var texts []string
for _, item := range v {
@@ -1483,47 +1346,6 @@ func (c *cardConverter) extractTableCellValue(data interface{}) string {
}
}
// goMapNextKey matches the start of the next key in a Go fmt map literal (space + identifier + colon).
var goMapNextKey = regexp.MustCompile(` [a-zA-Z_][a-zA-Z0-9_]*:`)
// goMapArrayTexts extracts "text" values from a Go-format slice-of-maps string,
// e.g. "[map[text:VIP] map[text:Premium]]" → ["VIP", "Premium"].
// Values may contain spaces; they are delimited by the next map key or by "]".
// Returns nil if the string doesn't look like this format.
func goMapArrayTexts(s string) []string {
if !strings.HasPrefix(s, "[") || !strings.Contains(s, "map[") {
return nil
}
const key = "text:"
var texts []string
rest := s
for {
idx := strings.Index(rest, key)
if idx < 0 {
break
}
after := rest[idx+len(key):]
bracketEnd := strings.Index(after, "]")
nextKey := goMapNextKey.FindStringIndex(after)
var end int
if nextKey != nil && (bracketEnd < 0 || nextKey[0] < bracketEnd) {
end = nextKey[0]
} else if bracketEnd >= 0 {
end = bracketEnd
} else {
if after != "" {
texts = append(texts, after)
}
break
}
if val := after[:end]; val != "" {
texts = append(texts, val)
}
rest = after[end:]
}
return texts
}
func (c *cardConverter) convertPerson(prop cardObj, _ string) string {
userID, _ := prop["userID"].(string)
if userID == "" {
@@ -1537,14 +1359,14 @@ func (c *cardConverter) convertPerson(prop cardObj, _ string) string {
}
if personName != "" {
if c.mode == cardModeDetailed {
return fmt.Sprintf("%s(open_id:%s)", personName, userID)
return fmt.Sprintf("@%s(open_id:%s)", personName, userID)
}
return personName
return "@" + personName
}
if c.mode == cardModeDetailed {
return fmt.Sprintf("user(open_id:%s)", userID)
return fmt.Sprintf("@user(open_id:%s)", userID)
}
return userID
return "@" + userID
}
// convertPersonV1 handles the v1 card schema person element.
@@ -1560,14 +1382,14 @@ func (c *cardConverter) convertPersonV1(prop cardObj, _ string) string {
personName := c.lookupPersonName(userID)
if personName != "" {
if c.mode == cardModeDetailed {
return fmt.Sprintf("%s(open_id:%s)", personName, userID)
return fmt.Sprintf("@%s(open_id:%s)", personName, userID)
}
return personName
return "@" + personName
}
if c.mode == cardModeDetailed {
return fmt.Sprintf("user(open_id:%s)", userID)
return fmt.Sprintf("@user(open_id:%s)", userID)
}
return userID
return "@" + userID
}
func (c *cardConverter) convertPersonList(prop cardObj) string {
@@ -1582,21 +1404,10 @@ func (c *cardConverter) convertPersonList(prop cardObj) string {
continue
}
personID, _ := pm["id"].(string)
personName := c.lookupPersonName(personID)
if personName != "" {
if c.mode == cardModeDetailed {
names = append(names, fmt.Sprintf("%s(open_id:%s)", personName, personID))
} else {
names = append(names, personName)
}
} else if personID != "" {
if c.mode == cardModeDetailed {
names = append(names, fmt.Sprintf("user(id:%s)", personID))
} else {
names = append(names, personID)
}
if c.mode == cardModeDetailed && personID != "" {
names = append(names, fmt.Sprintf("@user(id:%s)", personID))
} else {
names = append(names, "user")
names = append(names, "@user")
}
}
return strings.Join(names, ", ")
@@ -1604,15 +1415,8 @@ func (c *cardConverter) convertPersonList(prop cardObj) string {
func (c *cardConverter) convertAvatar(prop cardObj, _ string) string {
userID, _ := prop["userID"].(string)
personName := c.lookupPersonName(userID)
if personName != "" {
if c.mode == cardModeDetailed {
return fmt.Sprintf("👤 %s(open_id:%s)", personName, userID)
}
return "👤 " + personName
}
result := "👤"
if userID != "" {
if c.mode == cardModeDetailed && userID != "" {
result += "(id:" + userID + ")"
}
return result
@@ -1693,37 +1497,20 @@ func (c *cardConverter) lookupPersonName(userID string) string {
return ""
}
// lookupOptionUserName resolves a user display name from the attachment's option_users map,
// used for person-selector option labels.
func (c *cardConverter) lookupOptionUserName(userID string) string {
func (c *cardConverter) getImageToken(imageID string) string {
if c.attachment == nil {
return ""
}
if optUsers, ok := c.attachment["option_users"].(cardObj); ok {
if userInfo, ok := optUsers[userID].(cardObj); ok {
if content, ok := userInfo["content"].(string); ok {
return content
if images, ok := c.attachment["images"].(cardObj); ok {
if imageInfo, ok := images[imageID].(cardObj); ok {
if token, ok := imageInfo["token"].(string); ok {
return token
}
}
}
return ""
}
// getImageKeyAndToken returns the origin_key and token for an image ID from the attachment map.
// origin_key takes priority over token as the display-ready image reference.
func (c *cardConverter) getImageKeyAndToken(imageID string) (originKey, token string) {
if c.attachment == nil {
return "", ""
}
if images, ok := c.attachment["images"].(cardObj); ok {
if imageInfo, ok := images[imageID].(cardObj); ok {
originKey, _ = imageInfo["origin_key"].(string)
token, _ = imageInfo["token"].(string)
}
}
return originKey, token
}
type cardTextStyle struct {
bold bool
italic bool

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ import (
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -50,15 +51,11 @@ const maxBodyFileSize = 32 * 1024 * 1024 // 32 MB
func validateBodyFileMutex(bodyFlag, bodyFile string, validatePath func(string) error) error {
bodyEmpty := strings.TrimSpace(bodyFlag) == ""
if !bodyEmpty && bodyFile != "" {
return mailValidationError("--body and --body-file are mutually exclusive; pass exactly one").
WithParams(
mailInvalidParam("--body", "mutually exclusive with --body-file"),
mailInvalidParam("--body-file", "mutually exclusive with --body"),
)
return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one")
}
if bodyFile != "" {
if err := validatePath(bodyFile); err != nil {
return mailValidationParamError("--body-file", "--body-file: %v", err).WithCause(err)
return output.ErrValidation("--body-file: %v", err)
}
}
return nil
@@ -82,7 +79,7 @@ func resolveBodyFromFlags(runtime *common.RuntimeContext) (string, error) {
func validateRequiredResolvedBody(body string, hasTemplate bool, message string) error {
if !hasTemplate && strings.TrimSpace(body) == "" {
return mailValidationError("%s", message)
return output.ErrValidation(message)
}
return nil
}
@@ -98,15 +95,15 @@ func validateRequiredResolvedBody(body string, hasTemplate bool, message string)
func readBodyFile(fio fileio.FileIO, path string) (string, error) {
f, err := fio.Open(path)
if err != nil {
return "", mailValidationParamError("--body-file", "open --body-file %s: %v", path, err).WithCause(mailInputStatError(err))
return "", output.ErrValidation("open --body-file %s: %v", path, err)
}
defer f.Close()
buf, err := io.ReadAll(io.LimitReader(f, maxBodyFileSize+1))
if err != nil {
return "", mailValidationParamError("--body-file", "read --body-file %s: %v", path, err).WithCause(err)
return "", output.ErrValidation("read --body-file %s: %v", path, err)
}
if len(buf) > maxBodyFileSize {
return "", mailValidationParamError("--body-file", "--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024)
return "", output.ErrValidation("--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024)
}
return string(buf), nil
}

View File

@@ -49,7 +49,7 @@ func encodeTextCharset(body []byte, label string) ([]byte, error) {
}
enc, _ := htmlcharset.Lookup(label)
if enc == nil {
return nil, fmt.Errorf("unsupported charset %q", label) //nolint:forbidigo // intermediate draft charset error; mail command layer wraps into typed ValidationError.
return nil, fmt.Errorf("unsupported charset %q", label)
}
var buf bytes.Buffer
writer := transform.NewWriter(&buf, enc.NewEncoder())

View File

@@ -1,7 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft large-attachment parser errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -1,7 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft attachment limit errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -1,7 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft patch model errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -1,7 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft EML parser errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -1,7 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft patch application errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -1,7 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft calendar patch errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -1,7 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft serializer errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -4,10 +4,10 @@
package draft
import (
"fmt"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -31,13 +31,13 @@ func mailboxPath(mailboxID string, segments ...string) string {
// draft_id, the input draftID is echoed back so callers always have a
// non-empty identifier to round-trip.
func GetRaw(runtime *common.RuntimeContext, mailboxID, draftID string) (DraftRaw, error) {
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "drafts", draftID), map[string]interface{}{"format": "raw"}, nil)
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "drafts", draftID), map[string]interface{}{"format": "raw"}, nil)
if err != nil {
return DraftRaw{}, err
}
raw := extractRawEML(data)
if raw == "" {
return DraftRaw{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "API response missing draft raw EML; the backend returned an empty raw body for this draft")
return DraftRaw{}, fmt.Errorf("API response missing draft raw EML; the backend returned an empty raw body for this draft")
}
gotDraftID := extractDraftID(data)
if gotDraftID == "" {
@@ -55,13 +55,13 @@ func GetRaw(runtime *common.RuntimeContext, mailboxID, draftID string) (DraftRaw
// assembled the EML with emlbuilder; for high-level compose paths use the
// MailDraftCreate shortcut instead.
func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (DraftResult, error) {
data, err := runtime.CallAPITyped("POST", mailboxPath(mailboxID, "drafts"), nil, map[string]interface{}{"raw": rawEML})
data, err := runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts"), nil, map[string]interface{}{"raw": rawEML})
if err != nil {
return DraftResult{}, err
}
draftID := extractDraftID(data)
if draftID == "" {
return DraftResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "API response missing draft_id")
return DraftResult{}, fmt.Errorf("API response missing draft_id")
}
return DraftResult{
DraftID: draftID,
@@ -76,7 +76,7 @@ func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (Dr
// carries the (possibly re-issued) draft ID and the preview reference URL
// when the backend provides one.
func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML string) (DraftResult, error) {
data, err := runtime.CallAPITyped("PUT", mailboxPath(mailboxID, "drafts", draftID), nil, map[string]interface{}{"raw": rawEML})
data, err := runtime.CallAPI("PUT", mailboxPath(mailboxID, "drafts", draftID), nil, map[string]interface{}{"raw": rawEML})
if err != nil {
return DraftResult{}, err
}
@@ -99,7 +99,7 @@ func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (
if sendTime != "" {
bodyParams = map[string]interface{}{"send_time": sendTime}
}
return runtime.CallAPITyped("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams)
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams)
}
// extractDraftID returns the first non-empty draft identifier found in the

View File

@@ -53,7 +53,6 @@ import (
"time"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/mail/filecheck"
)
@@ -62,12 +61,9 @@ const MaxEMLSize = 25 * 1024 * 1024 // 25 MB
// readFile reads the named file and returns its contents via FileIO.
func readFile(fio fileio.FileIO, path string) ([]byte, error) {
if _, err := validate.SafeInputPath(path); err != nil {
return nil, fmt.Errorf("attachment %q: %w", path, err) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
f, err := fio.Open(path)
if err != nil {
return nil, fmt.Errorf("attachment %q: %w", path, err) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
return nil, fmt.Errorf("attachment %q: %w", path, err)
}
defer f.Close()
return io.ReadAll(f)
@@ -137,10 +133,10 @@ func New() Builder {
func validateHeaderValue(v string) error {
for _, r := range v {
if r != '\t' && (r < 0x20 || r == 0x7f) {
return fmt.Errorf("emlbuilder: header value contains control character: %q", v) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
return fmt.Errorf("emlbuilder: header value contains control character: %q", v)
}
if isHeaderDangerousUnicode(r) {
return fmt.Errorf("emlbuilder: header value contains dangerous Unicode character: %q", v) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
return fmt.Errorf("emlbuilder: header value contains dangerous Unicode character: %q", v)
}
}
return nil
@@ -169,11 +165,11 @@ func isHeaderDangerousUnicode(r rune) bool {
// or non-printable ASCII characters, as required by RFC 5322 field-name syntax.
func validateHeaderName(n string) error {
if strings.ContainsAny(n, ":\r\n") {
return fmt.Errorf("emlbuilder: header name contains ':', CR, or LF: %q", n) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
return fmt.Errorf("emlbuilder: header name contains ':', CR, or LF: %q", n)
}
for _, r := range n {
if r < 0x21 || r > 0x7e {
return fmt.Errorf("emlbuilder: header name contains non-printable character: %q", n) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
return fmt.Errorf("emlbuilder: header name contains non-printable character: %q", n)
}
}
return nil
@@ -183,7 +179,7 @@ func validateHeaderName(n string) error {
// escape the quoted-string encoding used by mail.Address.String() and inject headers.
func validateDisplayName(name string) error {
if strings.ContainsAny(name, "\r\n") {
return fmt.Errorf("emlbuilder: display name contains CR or LF: %q", name) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
return fmt.Errorf("emlbuilder: display name contains CR or LF: %q", name)
}
return nil
}
@@ -193,7 +189,7 @@ func validateDisplayName(name string) error {
func validateCID(cid string) error {
for _, r := range cid {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("emlbuilder: content ID contains control character: %q", cid) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
return fmt.Errorf("emlbuilder: content ID contains control character: %q", cid)
}
}
return nil
@@ -676,10 +672,10 @@ func (b Builder) Build() ([]byte, error) {
return nil, b.err
}
if b.from.Address == "" {
return nil, fmt.Errorf("emlbuilder: From address is required") //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
return nil, fmt.Errorf("emlbuilder: From address is required")
}
if !b.allowNoRecipients && len(b.to)+len(b.cc)+len(b.bcc) == 0 {
return nil, fmt.Errorf("emlbuilder: at least one recipient (To/CC/BCC) is required") //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
return nil, fmt.Errorf("emlbuilder: at least one recipient (To/CC/BCC) is required")
}
date := b.date
@@ -758,7 +754,7 @@ func (b Builder) Build() ([]byte, error) {
raw := buf.Bytes()
if len(raw) > MaxEMLSize {
return nil, fmt.Errorf("emlbuilder: EML size %.1f MB exceeds the %.0f MB limit", //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
return nil, fmt.Errorf("emlbuilder: EML size %.1f MB exceeds the %.0f MB limit",
float64(len(raw))/1024/1024, float64(MaxEMLSize)/1024/1024)
}
return raw, nil

View File

@@ -124,7 +124,7 @@ func CheckBlockedExtension(filename string) error {
return nil
}
if _, ok := blockedExtensions[ext]; ok {
return fmt.Errorf("file extension %q is not allowed as a mail attachment", "."+ext) //nolint:forbidigo // intermediate mail file-format check; mail command layer wraps into typed ValidationError.
return fmt.Errorf("file extension %q is not allowed as a mail attachment", "."+ext)
}
return nil
}
@@ -156,7 +156,7 @@ var allowedInlineMIMETypes = map[string]struct{}{
func CheckInlineImageFormat(filename string, content []byte) (string, error) {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
if _, ok := allowedInlineExtensions[ext]; !ok {
return "", fmt.Errorf("inline image extension %q is not allowed; supported formats: jpg, jpeg, png, gif, webp", ext) //nolint:forbidigo // intermediate mail file-format check; mail command layer wraps into typed ValidationError.
return "", fmt.Errorf("inline image extension %q is not allowed; supported formats: jpg, jpeg, png, gif, webp", ext)
}
detected := http.DetectContentType(content)
// DetectContentType may return params (e.g. "text/plain; charset=utf-8"),
@@ -165,7 +165,7 @@ func CheckInlineImageFormat(filename string, content []byte) (string, error) {
detected = strings.TrimSpace(detected[:i])
}
if _, ok := allowedInlineMIMETypes[detected]; !ok {
return "", fmt.Errorf("inline image content type %q does not match an allowed image format; supported: image/jpeg, image/png, image/gif, image/webp", detected) //nolint:forbidigo // intermediate mail file-format check; mail command layer wraps into typed ValidationError.
return "", fmt.Errorf("inline image content type %q does not match an allowed image format; supported: image/jpeg, image/png, image/gif, image/webp", detected)
}
return detected, nil
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
// flagName is a package-private snapshot of a pflag.Flag's identity.
@@ -21,7 +21,8 @@ type flagName struct {
}
// Candidate is a single suggested flag returned to the user when an
// unknown flag is detected.
// unknown flag is detected. It is serialised into the ErrorEnvelope's
// error.detail.candidates[] array.
type Candidate struct {
// Flag is the long-form spelling of the suggested flag, e.g. "--to".
Flag string `json:"flag"`
@@ -55,9 +56,9 @@ func InstallOnMail(svc *cobra.Command) {
svc.SetFlagErrorFunc(flagSuggestErrorFunc)
}
// flagSuggestErrorFunc converts pflag's unknown-flag errors into a typed
// validation error carrying candidate suggestions. Any other error is passed
// through unchanged so cobra's existing handling kicks in.
// flagSuggestErrorFunc converts pflag's unknown-flag errors into a
// structured *output.ExitError carrying candidate suggestions. Any other
// error is passed through unchanged so cobra's existing handling kicks in.
func flagSuggestErrorFunc(c *cobra.Command, err error) error {
if err == nil {
return nil
@@ -82,21 +83,22 @@ func flagSuggestErrorFunc(c *cobra.Command, err error) error {
matches = []Candidate{}
}
hint := buildHint(c, matches)
params := []errs.InvalidParam{{
Name: rawUnknownToken(token, isShorthand),
Reason: "unknown flag",
}}
for _, match := range matches {
reason := fmt.Sprintf("candidate (%s, distance=%d)", match.Reason, match.Distance)
if match.Shorthand != "" {
reason += fmt.Sprintf(", shorthand=-%s", match.Shorthand)
}
params = append(params, errs.InvalidParam{Name: match.Flag, Reason: reason})
detail := map[string]any{
"unknown": rawUnknownToken(token, isShorthand),
"command_path": c.CommandPath(),
"candidates": matches,
}
// Code is ExitAPI (=1), matching cobra's default unknown-flag exit
// code. The structured type discrimination lives in error.type.
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: err.Error(),
Hint: hint,
Detail: detail,
},
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, err.Error()).
WithHint("%s", hint).
WithParam(rawUnknownToken(token, isShorthand)).
WithParams(params...)
}
// parseUnknownToken extracts the offending flag name from a pflag error

View File

@@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
// --- suggest (long-flag) ---
@@ -175,41 +175,35 @@ func newFakeMailCmd() *cobra.Command {
return c
}
func requireFlagSuggestValidation(t *testing.T, got error) *errs.ValidationError {
t.Helper()
var validationErr *errs.ValidationError
require.True(t, errors.As(got, &validationErr), "expected *errs.ValidationError, got %T", got)
p, ok := errs.ProblemOf(got)
require.True(t, ok, "expected typed Problem")
assert.Equal(t, errs.CategoryValidation, p.Category)
assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype)
return validationErr
}
func paramReason(params []errs.InvalidParam, name string) (string, bool) {
for _, p := range params {
if p.Name == name {
return p.Reason, true
}
}
return "", false
}
func TestFlagSuggestErrorFunc_LongUnknown_ReturnsTypedValidation(t *testing.T) {
func TestFlagSuggestErrorFunc_LongUnknown_ReturnsExitError(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
validationErr := requireFlagSuggestValidation(t, got)
assert.Equal(t, "unknown flag: --tos", validationErr.Message)
assert.Equal(t, "--tos", validationErr.Param)
assert.Contains(t, validationErr.Hint, "--to")
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr), "expected *output.ExitError, got %T", got)
require.NotNil(t, exitErr.Detail)
assert.Equal(t, "unknown_flag", exitErr.Detail.Type)
assert.Equal(t, "unknown flag: --tos", exitErr.Detail.Message)
assert.Contains(t, exitErr.Detail.Hint, "--to")
reason, ok := paramReason(validationErr.Params, "--tos")
require.True(t, ok, "unknown flag should be included in params")
assert.Equal(t, "unknown flag", reason)
reason, ok = paramReason(validationErr.Params, "--to")
require.True(t, ok, "expected --to in candidate params")
assert.Contains(t, reason, "candidate (prefix")
detail, ok := exitErr.Detail.Detail.(map[string]any)
require.True(t, ok, "Detail.Detail should be map[string]any")
assert.Equal(t, "--tos", detail["unknown"])
assert.Equal(t, cmd.CommandPath(), detail["command_path"])
cands, ok := detail["candidates"].([]Candidate)
require.True(t, ok, "candidates should be []Candidate")
require.NotEmpty(t, cands)
var foundTo bool
for _, c := range cands {
if c.Flag == "--to" {
foundTo = true
assert.Equal(t, "prefix", c.Reason)
break
}
}
assert.True(t, foundTo, "expected --to in candidates")
}
func TestFlagSuggestErrorFunc_NotUnknownFlag_PassesThrough(t *testing.T) {
@@ -220,13 +214,14 @@ func TestFlagSuggestErrorFunc_NotUnknownFlag_PassesThrough(t *testing.T) {
assert.Same(t, in, got, "non-unknown-flag errors must be returned unchanged")
}
func TestFlagSuggestErrorFunc_TypedCategoryAndSubtype(t *testing.T) {
func TestFlagSuggestErrorFunc_ExitCodeIsOne(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
p, ok := errs.ProblemOf(got)
require.True(t, ok)
assert.Equal(t, errs.CategoryValidation, p.Category)
assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype)
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
// Hard contract — both compile-time and runtime guards:
assert.Equal(t, output.ExitAPI, exitErr.Code, "unknown_flag must use ExitAPI, not ExitValidation")
assert.Equal(t, 1, output.ExitAPI, "ExitAPI constant must remain 1")
}
// --- edge-case coverage ---
@@ -241,8 +236,9 @@ func TestInstallOnMail_InstallsHook(t *testing.T) {
InstallOnMail(c)
require.NotNil(t, c.FlagErrorFunc())
got := c.FlagErrorFunc()(c, errors.New("unknown flag: --tos"))
validationErr := requireFlagSuggestValidation(t, got)
assert.Equal(t, "--tos", validationErr.Param)
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr), "installed hook must produce *output.ExitError")
assert.Equal(t, "unknown_flag", exitErr.Detail.Type)
}
func TestFlagSuggestErrorFunc_NilError(t *testing.T) {
@@ -253,47 +249,50 @@ func TestFlagSuggestErrorFunc_NilError(t *testing.T) {
func TestFlagSuggestErrorFunc_LongUnknown_StripsValueTail(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos=alice@example.com"))
validationErr := requireFlagSuggestValidation(t, got)
assert.Equal(t, "--tos", validationErr.Param, "value tail must be stripped before echoing")
reason, ok := paramReason(validationErr.Params, "--tos")
require.True(t, ok)
assert.Equal(t, "unknown flag", reason)
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
detail := exitErr.Detail.Detail.(map[string]any)
assert.Equal(t, "--tos", detail["unknown"], "value tail must be stripped before echoing")
}
func TestFlagSuggestErrorFunc_ShorthandUnknown(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown shorthand flag: 'b' in -bXY"))
validationErr := requireFlagSuggestValidation(t, got)
assert.Equal(t, "-b", validationErr.Param)
reason, ok := paramReason(validationErr.Params, "-b")
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
detail := exitErr.Detail.Detail.(map[string]any)
assert.Equal(t, "-b", detail["unknown"])
cands, ok := detail["candidates"].([]Candidate)
require.True(t, ok)
assert.Equal(t, "unknown flag", reason)
// newFakeMailCmd has --body/-b; exact shorthand hit expected.
reason, ok = paramReason(validationErr.Params, "--body")
require.True(t, ok)
assert.Contains(t, reason, "candidate (prefix")
assert.Contains(t, reason, "shorthand=-b")
require.NotEmpty(t, cands)
assert.Equal(t, "--body", cands[0].Flag)
assert.Equal(t, "b", cands[0].Shorthand)
}
func TestFlagSuggestErrorFunc_ParamsAlwaysPresent(t *testing.T) {
func TestFlagSuggestErrorFunc_CandidatesAlwaysArray(t *testing.T) {
// A cobra command with no flags forces collectFlags → empty names →
// suggest → nil. The typed validation error must still expose the unknown
// flag in Params so downstream parsers have a stable structured field.
// suggest → nil. The envelope must still expose candidates as a
// non-nil []Candidate so the JSON wire shape is "candidates: []"
// rather than "candidates: null".
bare := &cobra.Command{Use: "mail"}
got := flagSuggestErrorFunc(bare, errors.New("unknown flag: --bogus"))
validationErr := requireFlagSuggestValidation(t, got)
assert.NotNil(t, validationErr.Params)
require.Len(t, validationErr.Params, 1)
assert.Equal(t, "--bogus", validationErr.Params[0].Name)
assert.Equal(t, "unknown flag", validationErr.Params[0].Reason)
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
detail := exitErr.Detail.Detail.(map[string]any)
cands, ok := detail["candidates"].([]Candidate)
require.True(t, ok, "candidates must be []Candidate even when empty")
assert.NotNil(t, cands, "candidates must be non-nil empty slice, not nil")
assert.Empty(t, cands)
}
func TestFlagSuggestErrorFunc_NoCandidatesUsesHelpHint(t *testing.T) {
cmd := newFakeMailCmd()
// Token with no plausible neighbor in {to, cc, subject, body}.
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --zzzzzzz"))
validationErr := requireFlagSuggestValidation(t, got)
assert.Contains(t, validationErr.Hint, "--help")
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
assert.Contains(t, exitErr.Detail.Hint, "--help")
}
func TestParseUnknownToken_EmptyAndMalformed(t *testing.T) {

View File

@@ -6,7 +6,6 @@ package mail
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
@@ -22,6 +21,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
@@ -111,7 +111,7 @@ func requireSenderForRequestReceipt(runtime *common.RuntimeContext, senderEmail
return nil
}
if strings.TrimSpace(senderEmail) == "" {
return mailValidationError(
return output.ErrValidation(
"--request-receipt requires a resolvable sender address; specify a sender address where supported, or ensure the draft has a From address")
}
return nil
@@ -130,10 +130,10 @@ func requireSenderForRequestReceipt(runtime *common.RuntimeContext, senderEmail
func validateHeaderAddress(addr string) error {
for _, r := range addr {
if r != '\t' && (r < 0x20 || r == 0x7f) {
return mailValidationError("address contains control character: %q", addr)
return fmt.Errorf("address contains control character: %q", addr)
}
if common.IsDangerousUnicode(r) {
return mailValidationError("address contains dangerous Unicode code point: %q", addr)
return fmt.Errorf("address contains dangerous Unicode code point: %q", addr)
}
}
return nil
@@ -324,7 +324,7 @@ func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string)
if mailboxID == "" {
mailboxID = "me"
}
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "profile"), nil, nil)
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "profile"), nil, nil)
if err != nil {
return "", err
}
@@ -336,7 +336,7 @@ func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string)
return email, nil
}
}
return "", mailInvalidResponseError("profile API returned no primary_email_address")
return "", fmt.Errorf("profile API returned no primary_email_address")
}
// extractPrimaryEmail returns the user's primary email address from a
@@ -503,14 +503,12 @@ func resolveFolderID(runtime *common.RuntimeContext, mailboxID, input string) (s
if err != nil {
return "", err
}
return resolveByName("folder", value, mailboxID, folders,
func(item folderInfo) string { return item.ID },
func(item folderInfo) string { return item.Name },
)
return resolveByID("folder", value, mailboxID, folders, func(item folderInfo) string { return item.ID })
}
// resolveFolderName accepts either a folder ID or a folder name and returns
// the canonical folder ID.
// the human-readable folder name. Used for output rendering where the user
// wants to see the name they originally chose, not the opaque ID.
func resolveFolderName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
@@ -544,14 +542,11 @@ func resolveLabelID(runtime *common.RuntimeContext, mailboxID, input string) (st
if err != nil {
return "", err
}
return resolveByName("label", value, mailboxID, labels,
func(item labelInfo) string { return item.ID },
func(item labelInfo) string { return item.Name },
)
return resolveByID("label", value, mailboxID, labels, func(item labelInfo) string { return item.ID })
}
// resolveLabelName accepts either a label ID or a label name and returns
// the canonical label ID (mirror of resolveFolderName for labels).
// the human-readable label name (mirror of resolveFolderName for labels).
func resolveLabelName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
@@ -851,11 +846,9 @@ func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]fol
if err := validateFolderReadScope(runtime); err != nil {
return nil, err
}
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "folders"), nil, nil)
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "folders"), nil, nil)
if err != nil {
return nil, mailAppendProblemHint(
mailDecorateProblemMessage(err, "unable to resolve --folder: failed to list folders"),
resolveLookupHint("folder", mailboxID))
return nil, output.ErrValidation("unable to resolve --folder: failed to list folders (%v). %s", err, resolveLookupHint("folder", mailboxID))
}
items, _ := data["items"].([]interface{})
folders := make([]folderInfo, 0, len(items))
@@ -878,11 +871,9 @@ func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labe
if err := validateLabelReadScope(runtime); err != nil {
return nil, err
}
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "labels"), nil, nil)
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "labels"), nil, nil)
if err != nil {
return nil, mailAppendProblemHint(
mailDecorateProblemMessage(err, "unable to resolve --label: failed to list labels"),
resolveLookupHint("label", mailboxID))
return nil, output.ErrValidation("unable to resolve --label: failed to list labels (%v). %s", err, resolveLookupHint("label", mailboxID))
}
items, _ := data["items"].([]interface{})
labels := make([]labelInfo, 0, len(items))
@@ -900,9 +891,26 @@ func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labe
return labels, nil
}
// resolveByName looks up input as an exact ID first, then as a name, and
// returns the matching ID. Errors out on duplicate names so callers get a clear
// "ambiguous name" signal rather than silently picking one match.
// resolveByID looks up input as an ID in items, returning input itself when
// found. kind ("folder" / "label") and mailboxID are used to construct the
// not-found hint. Generic over T so the same logic serves both folder and
// label tables.
func resolveByID[T any](kind, input, mailboxID string, items []T, idFn func(T) string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", nil
}
for _, item := range items {
if id := idFn(item); id != "" && id == value {
return id, nil
}
}
return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
}
// resolveByName looks up input as a name in items and returns the matching
// ID. Errors out on duplicates so callers get a clear "ambiguous name"
// signal rather than silently picking one match.
func resolveByName[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
@@ -911,7 +919,7 @@ func resolveByName[T any](kind, input, mailboxID string, items []T, idFn func(T)
for _, item := range items {
if id := idFn(item); id != "" && id == value {
return id, nil
return "", output.ErrValidation("%s %q looks like an ID; please use %s_id", kind, value, kind)
}
}
@@ -935,9 +943,9 @@ func resolveByName[T any](kind, input, mailboxID string, items []T, idFn func(T)
return matches[0], nil
}
if len(matches) > 1 {
return "", mailValidationError("%s name %q matches multiple IDs (%s); please use an ID", kind, value, strings.Join(matches, ","))
return "", output.ErrValidation("%s name %q matches multiple IDs (%s); please use an ID", kind, value, strings.Join(matches, ","))
}
return "", mailValidationError("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
}
// resolveNameValueByID is the inverse of resolveByID: it looks up an ID
@@ -951,18 +959,18 @@ func resolveNameValueByID[T any](kind, input, mailboxID string, items []T, idFn
if id := idFn(item); id != "" && id == value {
name := strings.TrimSpace(nameFn(item))
if name == "" {
return "", mailValidationError("%s %q has empty name; cannot use it with query filters", kind, value)
return "", output.ErrValidation("%s %q has empty name; cannot use it with query filters", kind, value)
}
return name, nil
}
}
return "", mailValidationError("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
}
// resolveNameValueByNameAllowDuplicates looks up input as an exact ID first,
// then as a name, and returns the matching name. Duplicate names are tolerated
// by returning the first match. Used in query-style contexts where ambiguity is
// acceptable because the API itself disambiguates server-side.
// resolveNameValueByNameAllowDuplicates is like resolveByName but tolerates
// duplicate names — returning the first match. Used in query-style contexts
// where ambiguity is acceptable because the API itself disambiguates server-
// side.
func resolveNameValueByNameAllowDuplicates[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
@@ -970,11 +978,7 @@ func resolveNameValueByNameAllowDuplicates[T any](kind, input, mailboxID string,
}
for _, item := range items {
if id := idFn(item); id != "" && id == value {
name := strings.TrimSpace(nameFn(item))
if name == "" {
return "", mailValidationError("%s %q has empty name; cannot use it with query filters", kind, value)
}
return name, nil
return "", output.ErrValidation("%s %q looks like an ID; please use %s_id", kind, value, kind)
}
}
lower := strings.ToLower(value)
@@ -985,7 +989,7 @@ func resolveNameValueByNameAllowDuplicates[T any](kind, input, mailboxID string,
}
return name, nil
}
return "", mailValidationError("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
}
// resolveLookupHint returns the CLI command a user should run to list
@@ -1011,13 +1015,13 @@ func resolveLookupHint(kind, mailboxID string) string {
// html=false -> format=plain_text_full (server omits body_html)
func fetchFullMessage(runtime *common.RuntimeContext, mailboxID, messageID string, html bool) (map[string]interface{}, error) {
params := map[string]interface{}{"format": messageGetFormat(html)}
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "messages", messageID), params, nil)
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "messages", messageID), params, nil)
if err != nil {
return nil, err
}
msg, _ := data["message"].(map[string]interface{})
if msg == nil {
return nil, mailInvalidResponseError("API response missing message field")
return nil, fmt.Errorf("API response missing message field")
}
return msg, nil
}
@@ -1035,7 +1039,7 @@ func fetchFullMessages(runtime *common.RuntimeContext, mailboxID string, message
if end > len(messageIDs) {
end = len(messageIDs)
}
data, err := runtime.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_get"), nil, map[string]interface{}{
data, err := runtime.CallAPI("POST", mailboxPath(mailboxID, "messages", "batch_get"), nil, map[string]interface{}{
"format": messageGetFormat(html),
"message_ids": messageIDs[start:end],
})
@@ -1228,7 +1232,7 @@ type calendarEventOutput struct {
// It never returns an error: failed batches/IDs are converted to structured warnings so caller can continue.
func fetchAttachmentURLs(runtime *common.RuntimeContext, mailboxID, messageID string, ids []string) (map[string]string, []warningEntry) {
callAPI := func(url string) (map[string]interface{}, error) {
return runtime.CallAPITyped("GET", url, nil, nil)
return runtime.CallAPI("GET", url, nil, nil)
}
emitWarning := func(w warningEntry) {
fmt.Fprintf(runtime.IO().ErrOut, "warning: code=%s message_id=%s attachment_id=%s retryable=%t detail=%s\n", w.Code, w.MessageID, w.AttachmentID, w.Retryable, w.Detail)
@@ -1664,7 +1668,7 @@ func validateForwardAttachmentURLs(src composeSourceMessage) error {
}
}
if len(missing) > 0 {
return mailInvalidResponseError("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
return fmt.Errorf("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
}
return nil
}
@@ -1679,7 +1683,7 @@ func validateInlineImageURLs(src composeSourceMessage) error {
}
}
if len(missing) > 0 {
return mailInvalidResponseError("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
return fmt.Errorf("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
}
return nil
}
@@ -1782,51 +1786,41 @@ func toInlineSourceParts(out normalizedMessageForCompose) []inlineSourcePart {
func downloadAttachmentContent(runtime *common.RuntimeContext, downloadURL string) ([]byte, error) {
u, err := url.Parse(downloadURL)
if err != nil {
return nil, mailInvalidResponseError("invalid attachment download URL: %v", err).WithCause(err)
return nil, fmt.Errorf("invalid attachment download URL: %w", err)
}
if u.Scheme != "https" {
return nil, mailInvalidResponseError("attachment download URL must use https (got %q)", u.Scheme)
return nil, fmt.Errorf("attachment download URL must use https (got %q)", u.Scheme)
}
if u.Host == "" {
return nil, mailInvalidResponseError("attachment download URL has no host")
return nil, fmt.Errorf("attachment download URL has no host")
}
httpClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to get HTTP client: %v", err).WithCause(err)
return nil, fmt.Errorf("failed to get HTTP client: %w", err)
}
req, err := http.NewRequestWithContext(runtime.Ctx(), http.MethodGet, downloadURL, nil)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to build attachment download request: %v", err).WithCause(err)
return nil, fmt.Errorf("failed to build attachment download request: %w", err)
}
// Do NOT send Authorization: the download_url is a pre-signed URL with an
// authcode embedded in the query string. Attaching the Bearer token would
// leak it to whatever host the URL points at (SSRF / token exfiltration).
resp, err := httpClient.Do(req)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to download attachment: %v", err).WithCause(err)
return nil, fmt.Errorf("failed to download attachment: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
if resp.StatusCode >= 500 {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "failed to download attachment: HTTP %d", resp.StatusCode).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return nil, errs.NewAPIError(subtype, "failed to download attachment: HTTP %d", resp.StatusCode).WithCode(resp.StatusCode)
return nil, fmt.Errorf("failed to download attachment: HTTP %d", resp.StatusCode)
}
limitedReader := io.LimitReader(resp.Body, int64(MaxAttachmentDownloadBytes)+1)
data, err := io.ReadAll(limitedReader)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to read attachment content: %v", err).WithCause(err)
return nil, fmt.Errorf("failed to read attachment content: %w", err)
}
if len(data) > MaxAttachmentDownloadBytes {
return nil, mailFailedPreconditionError("attachment download exceeds %d MB size limit", MaxAttachmentDownloadBytes/1024/1024).
WithHint("download or forward this large attachment outside the inline/small-attachment path")
return nil, fmt.Errorf("attachment download exceeds %d MB size limit", MaxAttachmentDownloadBytes/1024/1024)
}
return data, nil
}
@@ -2068,7 +2062,7 @@ func parsePriority(value string) (string, error) {
case "low":
return "5", nil
default:
return "", mailValidationParamError("--priority", "invalid --priority value %q: expected high, normal, or low", value)
return "", fmt.Errorf("invalid --priority value %q: expected high, normal, or low", value)
}
}
@@ -2238,7 +2232,7 @@ func validateInlineCIDs(html string, userCIDs, extraCIDs []string) error {
if len(userCIDs) > 0 {
orphaned := draftpkg.FindOrphanedCIDs(html, userCIDs)
if len(orphaned) > 0 {
return mailValidationParamError("--inline", "inline images with cids %v are not referenced by any <img src=\"cid:...\"> in the HTML body and will appear as unexpected attachments; remove unused --inline entries or add matching <img> tags", orphaned)
return fmt.Errorf("inline images with cids %v are not referenced by any <img src=\"cid:...\"> in the HTML body and will appear as unexpected attachments; remove unused --inline entries or add matching <img> tags", orphaned)
}
}
return nil
@@ -2257,7 +2251,7 @@ func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Bui
for _, img := range images {
content, err := downloadAttachmentContent(runtime, img.DownloadURL)
if err != nil {
return bld, nil, 0, mailDecorateProblemMessage(err, "failed to download inline resource %s", img.Filename)
return bld, nil, 0, fmt.Errorf("failed to download inline resource %s: %w", img.Filename, err)
}
cid := normalizeInlineCID(img.CID)
if cid == "" {
@@ -2290,14 +2284,14 @@ func parseInlineSpecs(raw string) ([]InlineSpec, error) {
}
var specs []InlineSpec
if err := json.Unmarshal([]byte(raw), &specs); err != nil {
return nil, mailValidationParamError("--inline", "--inline must be a JSON array, e.g. '[{\"cid\":\"a1b2c3d4e5f6a7b8c9d0\",\"file_path\":\"./banner.png\"}]': %v", err).WithCause(err)
return nil, fmt.Errorf("--inline must be a JSON array, e.g. '[{\"cid\":\"a1b2c3d4e5f6a7b8c9d0\",\"file_path\":\"./banner.png\"}]': %w", err)
}
for i, s := range specs {
if strings.TrimSpace(s.CID) == "" {
return nil, mailValidationParamError("--inline", "--inline entry %d: \"cid\" must not be empty", i)
return nil, fmt.Errorf("--inline entry %d: \"cid\" must not be empty", i)
}
if strings.TrimSpace(s.FilePath) == "" {
return nil, mailValidationParamError("--inline", "--inline entry %d: \"file_path\" must not be empty", i)
return nil, fmt.Errorf("--inline entry %d: \"file_path\" must not be empty", i)
}
}
return specs, nil
@@ -2324,11 +2318,7 @@ func validateEventSendTimeExclusion(runtime *common.RuntimeContext) error {
}
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
if runtime.Str(f) != "" {
return mailValidationError("--send-time and --event-* are mutually exclusive: a calendar invitation must be sent immediately so recipients can respond before the event").
WithParams(
mailInvalidParam("--send-time", "mutually exclusive with --event-*"),
mailInvalidParam("--event-*", "mutually exclusive with --send-time"),
)
return common.FlagErrorf("--send-time and --event-* are mutually exclusive: a calendar invitation must be sent immediately so recipients can respond before the event")
}
}
return nil
@@ -2342,15 +2332,15 @@ func validateSendTime(runtime *common.RuntimeContext) error {
return nil
}
if !runtime.Bool("confirm-send") {
return mailValidationParamError("--send-time", "--send-time requires --confirm-send to be set")
return output.ErrValidation("--send-time requires --confirm-send to be set")
}
ts, err := strconv.ParseInt(sendTime, 10, 64)
if err != nil {
return mailValidationParamError("--send-time", "--send-time must be a valid Unix timestamp in seconds, got %q", sendTime).WithCause(err)
return output.ErrValidation("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
}
minTime := time.Now().Unix() + 5*60
if ts < minTime {
return mailValidationParamError("--send-time", "--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
return output.ErrValidation("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
}
return nil
}
@@ -2439,12 +2429,7 @@ func validateLabelReadScope(runtime *common.RuntimeContext) error {
// all three (to/cc/bcc) are empty or whitespace-only.
func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error {
if strings.TrimSpace(to) == "" && strings.TrimSpace(cc) == "" && strings.TrimSpace(bcc) == "" {
return mailValidationError("at least one recipient (--to, --cc, or --bcc) is required").
WithParams(
mailInvalidParam("--to", "at least one recipient is required"),
mailInvalidParam("--cc", "at least one recipient is required"),
mailInvalidParam("--bcc", "at least one recipient is required"),
)
return fmt.Errorf("at least one recipient (--to, --cc, or --bcc) is required")
}
return validateRecipientCount(to, cc, bcc)
}
@@ -2454,12 +2439,7 @@ func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error {
func validateRecipientCount(to, cc, bcc string) error {
count := len(ParseMailboxList(to)) + len(ParseMailboxList(cc)) + len(ParseMailboxList(bcc))
if count > MaxRecipientCount {
return mailValidationError("total recipient count %d exceeds the limit of %d (To + CC + BCC combined)", count, MaxRecipientCount).
WithParams(
mailInvalidParam("--to", "recipient count contributes to combined limit"),
mailInvalidParam("--cc", "recipient count contributes to combined limit"),
mailInvalidParam("--bcc", "recipient count contributes to combined limit"),
)
return fmt.Errorf("total recipient count %d exceeds the limit of %d (To + CC + BCC combined)", count, MaxRecipientCount)
}
return nil
}
@@ -2471,14 +2451,10 @@ func validateRecipientCount(to, cc, bcc string) error {
func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFlag string, plainText bool, body string) error {
if strings.TrimSpace(inlineFlag) != "" {
if plainText {
return mailValidationError("--inline is not supported with --plain-text (inline images require HTML body)").
WithParams(
mailInvalidParam("--inline", "requires HTML body"),
mailInvalidParam("--plain-text", "mutually exclusive with --inline"),
)
return output.ErrValidation("--inline is not supported with --plain-text (inline images require HTML body)")
}
if body != "" && !bodyIsHTML(body) {
return mailValidationParamError("--inline", "--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
return output.ErrValidation("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
}
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
@@ -2560,12 +2536,7 @@ func validateEventFlags(runtime *common.RuntimeContext) error {
hasAll := summary != "" && start != "" && end != ""
if hasAny && !hasAll {
return mailValidationError("--event-summary, --event-start, and --event-end must all be provided together").
WithParams(
mailInvalidParam("--event-summary", "required with --event-start/--event-end"),
mailInvalidParam("--event-start", "required with --event-summary/--event-end"),
mailInvalidParam("--event-end", "required with --event-summary/--event-start"),
)
return output.ErrValidation("--event-summary, --event-start, and --event-end must all be provided together")
}
if summary == "" {
return nil
@@ -2582,14 +2553,14 @@ func validateEventFlags(runtime *common.RuntimeContext) error {
func parseEventTimeRange(start, end string) (time.Time, time.Time, error) {
startT, err := parseISO8601(start)
if err != nil {
return time.Time{}, time.Time{}, mailValidationError("start: invalid ISO 8601 time %q", start).WithCause(err)
return time.Time{}, time.Time{}, fmt.Errorf("start: invalid ISO 8601 time %q", start)
}
endT, err := parseISO8601(end)
if err != nil {
return time.Time{}, time.Time{}, mailValidationError("end: invalid ISO 8601 time %q", end).WithCause(err)
return time.Time{}, time.Time{}, fmt.Errorf("end: invalid ISO 8601 time %q", end)
}
if !endT.After(startT) {
return time.Time{}, time.Time{}, mailValidationError("end time must be after start time")
return time.Time{}, time.Time{}, fmt.Errorf("end time must be after start time")
}
return startT, endT, nil
}
@@ -2598,27 +2569,12 @@ func parseEventTimeRange(start, end string) (time.Time, time.Time, error) {
// error with the caller's flag-name prefix so users see the exact flag
// that caused the failure.
func prefixEventRangeError(flagPrefix string, err error) error {
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
var validationErr *errs.ValidationError
msg := p.Message
msg := err.Error()
switch {
case strings.HasPrefix(msg, "start: "):
p.Message = fmt.Sprintf("%sstart: %s", flagPrefix, strings.TrimPrefix(msg, "start: "))
p.Subtype = errs.SubtypeInvalidArgument
if strings.HasPrefix(flagPrefix, "--") && errors.As(err, &validationErr) {
validationErr.Param = flagPrefix + "start"
}
return err
return fmt.Errorf("%sstart: %s", flagPrefix, strings.TrimPrefix(msg, "start: "))
case strings.HasPrefix(msg, "end: "):
p.Message = fmt.Sprintf("%send: %s", flagPrefix, strings.TrimPrefix(msg, "end: "))
p.Subtype = errs.SubtypeInvalidArgument
if strings.HasPrefix(flagPrefix, "--") && errors.As(err, &validationErr) {
validationErr.Param = flagPrefix + "end"
}
return err
return fmt.Errorf("%send: %s", flagPrefix, strings.TrimPrefix(msg, "end: "))
default:
return err
}
@@ -2639,7 +2595,7 @@ func parseISO8601(s string) (time.Time, error) {
return t, nil
}
}
return time.Time{}, mailValidationError("cannot parse %q as ISO 8601", s)
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
}
// buildCalendarBody generates an ICS VCALENDAR from compose flags and returns the bytes.
@@ -2658,54 +2614,9 @@ func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAdd
// bot uses tenant access token; "me" cannot be resolved to a user mailbox under TAT.
func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
if runtime.IsBot() && runtime.Str("mailbox") == "me" {
return mailValidationParamError("--mailbox",
"--as bot does not support --mailbox me: bot identity uses a tenant token and cannot resolve \"me\" to a user mailbox; "+
return output.ErrValidation(
"--as bot does not support --mailbox me: bot identity uses a tenant token and cannot resolve \"me\" to a user mailbox; " +
"pass an explicit email address, e.g. --mailbox alice@example.com")
}
return nil
}
// validateMessageIDs parses and validates the existing +messages comma-separated
// flag format. Unlike splitByComma, it keeps empty entries so "id1,,id2" fails
// locally. It intentionally does not enforce the server-side single-call limit:
// fetchFullMessages chunks backend requests into batches of 20.
func validateMessageIDs(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids is required; provide one or more message IDs separated by commas")
}
parts := strings.Split(raw, ",")
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
id := strings.TrimSpace(part)
if id == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
if part != id {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
}
if err := validateBatchGetMessageID(id, i); err != nil {
return nil, err
}
if _, ok := seen[id]; ok {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): duplicate message ID is not allowed", i+1, id)
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func validateBatchGetMessageID(id string, index int) error {
if strings.Trim(id, "0123456789") == "" {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported by mail +messages; pass the Open API message_id from mail output", index+1, id)
}
decoded, rawErr := base64.RawURLEncoding.DecodeString(id)
if rawErr != nil {
decoded, rawErr = base64.URLEncoding.DecodeString(id)
}
if rawErr != nil || len(decoded) == 0 {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): expected a base64url Open API mail message_id from mail output", index+1, id)
}
return nil
}

View File

@@ -1353,34 +1353,6 @@ func TestValidateComposeInlineAndAttachments(t *testing.T) {
})
}
func TestResolveByNameAcceptsExactID(t *testing.T) {
folders := []folderInfo{{ID: "fld_custom", Name: "Team"}}
got, err := resolveByName("folder", "fld_custom", "me", folders,
func(item folderInfo) string { return item.ID },
func(item folderInfo) string { return item.Name },
)
if err != nil {
t.Fatalf("resolveByName returned error: %v", err)
}
if got != "fld_custom" {
t.Fatalf("resolveByName exact ID = %q, want fld_custom", got)
}
}
func TestResolveNameValueByNameAllowDuplicatesAcceptsExactID(t *testing.T) {
folders := []folderInfo{{ID: "fld_custom", Name: "Parent/Team"}}
got, err := resolveNameValueByNameAllowDuplicates("folder", "fld_custom", "me", folders,
func(item folderInfo) string { return item.ID },
func(item folderInfo) string { return item.Name },
)
if err != nil {
t.Fatalf("resolveNameValueByNameAllowDuplicates returned error: %v", err)
}
if got != "Parent/Team" {
t.Fatalf("query name for exact ID = %q, want Parent/Team", got)
}
}
// newRequestReceiptRuntime registers the --request-receipt bool flag alone
// (no --from), so requireSenderForRequestReceipt tests can drive the flag
// directly without pulling in unrelated compose plumbing.
@@ -1550,16 +1522,16 @@ func TestParseEventTimeRange_InvalidEnd(t *testing.T) {
}
func TestPrefixEventRangeError(t *testing.T) {
start := mailValidationError("start: invalid ISO 8601 time %q", "x")
start := fmt.Errorf("start: invalid ISO 8601 time %q", "x")
if got := prefixEventRangeError("--event-", start).Error(); got != `--event-start: invalid ISO 8601 time "x"` {
t.Errorf("got %q", got)
}
end := mailValidationError("end: invalid ISO 8601 time %q", "x")
end := fmt.Errorf("end: invalid ISO 8601 time %q", "x")
if got := prefixEventRangeError("--set-event-", end).Error(); got != `--set-event-end: invalid ISO 8601 time "x"` {
t.Errorf("got %q", got)
}
// Non-prefixed error passes through unchanged.
other := mailValidationError("end time must be after start time")
other := fmt.Errorf("end time must be after start time")
if got := prefixEventRangeError("--event-", other).Error(); got != "end time must be after start time" {
t.Errorf("got %q", got)
}

View File

@@ -14,7 +14,6 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
@@ -122,11 +121,11 @@ func statAttachmentFiles(fio fileio.FileIO, paths []string) ([]attachmentFile, e
}
name := filepath.Base(p)
if err := filecheck.CheckBlockedExtension(name); err != nil {
return nil, mailValidationError("%v", err).WithCause(err)
return nil, err
}
info, err := fio.Stat(p)
if err != nil {
return nil, mailInputStatError(err)
return nil, fmt.Errorf("failed to stat attachment %s: %w", p, err)
}
files = append(files, attachmentFile{
Path: p,
@@ -145,7 +144,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
}
userOpenId := runtime.UserOpenId()
if userOpenId == "" {
return nil, mailFailedPreconditionError("large attachment upload requires user identity (user open_id not available)")
return nil, fmt.Errorf("large attachment upload requires user identity (user open_id not available)")
}
results := make([]largeAttachmentResult, 0, len(files))
@@ -157,7 +156,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
err error
)
if f.Data != nil {
fileToken, err = common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FileName: f.FileName,
FileSize: f.Size,
ParentType: "email",
@@ -165,7 +164,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
Reader: bytes.NewReader(f.Data),
})
} else if f.Size <= common.MaxDriveMediaUploadSinglePartSize {
fileToken, err = common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: f.Path,
FileName: f.FileName,
FileSize: f.Size,
@@ -173,7 +172,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
ParentNode: &userOpenId,
})
} else {
fileToken, err = common.UploadDriveMediaMultipartTyped(runtime, common.DriveMediaMultipartUploadConfig{
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: f.Path,
FileName: f.FileName,
FileSize: f.Size,
@@ -182,7 +181,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
})
}
if err != nil {
return nil, mailDecorateProblemMessage(err, "failed to upload large attachment %s", f.FileName)
return nil, fmt.Errorf("failed to upload large attachment %s: %w", f.FileName, err)
}
results = append(results, largeAttachmentResult{
@@ -398,7 +397,7 @@ func processLargeAttachments(
) (emlbuilder.Builder, error) {
totalCount := extraAttachCount + len(attachPaths)
if totalCount > MaxAttachmentCount {
return bld, mailFailedPreconditionError("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount)
return bld, fmt.Errorf("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount)
}
files, err := statAttachmentFiles(runtime.FileIO(), attachPaths)
@@ -408,7 +407,7 @@ func processLargeAttachments(
for _, f := range files {
if f.Size > MaxLargeAttachmentSize {
return bld, mailFailedPreconditionError("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
return bld, fmt.Errorf("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
f.FileName, float64(f.Size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024)
}
}
@@ -423,7 +422,7 @@ func processLargeAttachments(
}
if htmlBody == "" && textBody == "" {
return bld, mailFailedPreconditionError("large attachments require a body; " +
return bld, fmt.Errorf("large attachments require a body; " +
"empty messages cannot include the download link")
}
@@ -432,7 +431,7 @@ func processLargeAttachments(
for _, f := range files {
totalBytes += f.Size
}
return bld, mailFailedPreconditionError("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
return bld, fmt.Errorf("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
"large attachment upload requires user identity (--as user)",
float64(totalBytes)/1024/1024)
}
@@ -456,7 +455,7 @@ func processLargeAttachments(
}
idsJSON, err := json.Marshal(ids)
if err != nil {
return bld, errs.NewInternalError(errs.SubtypeSDKError, "failed to encode large attachment IDs: %v", err).WithCause(err)
return bld, fmt.Errorf("failed to encode large attachment IDs: %w", err)
}
bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, base64.StdEncoding.EncodeToString(idsJSON))
@@ -589,7 +588,7 @@ func preprocessLargeAttachmentsForDraftEdit(
// Check 3GB single file limit.
for _, f := range files {
if f.Size > MaxLargeAttachmentSize {
return patch, mailFailedPreconditionError("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
return patch, fmt.Errorf("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
f.FileName, float64(f.Size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024)
}
}
@@ -607,7 +606,7 @@ func preprocessLargeAttachmentsForDraftEdit(
hasHTML := draftpkg.FindHTMLBodyPart(snapshot.Body) != nil
hasText := draftpkg.FindTextBodyPart(snapshot.Body) != nil
if !hasHTML && !hasText {
return patch, mailFailedPreconditionError("large attachments require a body; " +
return patch, fmt.Errorf("large attachments require a body; " +
"empty drafts cannot include the download link")
}
@@ -617,7 +616,7 @@ func preprocessLargeAttachmentsForDraftEdit(
for _, f := range files {
totalBytes += f.Size
}
return patch, mailFailedPreconditionError("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
return patch, fmt.Errorf("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
"large attachment upload requires user identity (--as user)",
float64(totalBytes)/1024/1024)
}
@@ -673,7 +672,7 @@ func preprocessLargeAttachmentsForDraftEdit(
}
idsJSON, err := json.Marshal(merged)
if err != nil {
return patch, errs.NewInternalError(errs.SubtypeSDKError, "failed to encode large attachment IDs: %v", err).WithCause(err)
return patch, fmt.Errorf("failed to encode large attachment IDs: %w", err)
}
headerValue := base64.StdEncoding.EncodeToString(idsJSON)
if existingIdx >= 0 {

View File

@@ -974,7 +974,7 @@ func TestStatAttachmentFiles_FileNotFound(t *testing.T) {
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "cannot read file") {
if !strings.Contains(err.Error(), "failed to stat") {
t.Errorf("unexpected error: %v", err)
}
}

View File

@@ -395,7 +395,8 @@ func sanitiseStyleAttr(raw string) (cleaned string, dropped []string) {
return cleaned, dropped
}
// hintForBlockedTag returns a hint for an error-blocked tag.
// hintForBlockedTag returns a hint for an error-blocked tag (matching
// the `output.ErrWithHint` convention used elsewhere in the cli).
func hintForBlockedTag(tag string) string {
switch tag {
case "script":

View File

@@ -59,7 +59,7 @@ var MailDeclineReceipt = common.Shortcut{
msg, err := fetchFullMessage(runtime, mailboxID, messageID, false)
if err != nil {
return mailDecorateProblemMessage(err, "failed to fetch original message")
return fmt.Errorf("failed to fetch original message: %w", err)
}
out := map[string]interface{}{
@@ -77,14 +77,14 @@ var MailDeclineReceipt = common.Shortcut{
return nil
}
if _, err := runtime.CallAPITyped("PUT",
if _, err := runtime.CallAPI("PUT",
mailboxPath(mailboxID, "messages", messageID, "modify"),
nil,
map[string]interface{}{
"remove_label_ids": []string{readReceiptRequestLabel},
},
); err != nil {
return mailDecorateProblemMessage(err, "failed to clear READ_RECEIPT_REQUEST label")
return fmt.Errorf("failed to clear READ_RECEIPT_REQUEST label: %w", err)
}
out["declined"] = true

View File

@@ -9,6 +9,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -90,7 +91,7 @@ var MailDraftCreate = common.Shortcut{
return err
}
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
return mailValidationParamError("--subject", "--subject is required; pass the final email subject (or use --template-id)")
return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)")
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
@@ -175,10 +176,10 @@ var MailDraftCreate = common.Shortcut{
})
}
if strings.TrimSpace(input.Subject) == "" {
return mailValidationParamError("--subject", "effective subject is empty after applying template; pass --subject explicitly")
return output.ErrValidation("effective subject is empty after applying template; pass --subject explicitly")
}
if strings.TrimSpace(input.Body) == "" {
return mailValidationParamError("--body", "effective body is empty after applying template; pass --body explicitly")
return output.ErrValidation("effective body is empty after applying template; pass --body explicitly")
}
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
if err != nil {
@@ -191,7 +192,7 @@ var MailDraftCreate = common.Shortcut{
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return mailDecorateProblemMessage(err, "create draft failed")
return fmt.Errorf("create draft failed: %w", err)
}
out := map[string]interface{}{"draft_id": draftResult.DraftID}
if draftResult.Reference != "" {
@@ -249,7 +250,7 @@ func buildRawEMLForDraftCreate(
senderEmail := resolveComposeSenderEmail(runtime)
if senderEmail == "" {
return "", lintApplied, lintBlocked, mailValidationParamError("--from", "unable to determine sender email; please specify --from explicitly")
return "", lintApplied, lintBlocked, fmt.Errorf("unable to determine sender email; please specify --from explicitly")
}
if err := validateRecipientCount(input.To, input.CC, input.BCC); err != nil {
@@ -284,7 +285,7 @@ func buildRawEMLForDraftCreate(
}
inlineSpecs, parseErr := parseInlineSpecs(input.Inline)
if parseErr != nil {
return "", lintApplied, lintBlocked, parseErr
return "", lintApplied, lintBlocked, output.ErrValidation("%v", parseErr)
}
var autoResolvedPaths []string
var composedHTMLBody string
@@ -299,7 +300,7 @@ func buildRawEMLForDraftCreate(
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
if resolveErr != nil {
return "", lintApplied, lintBlocked, mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
return "", lintApplied, lintBlocked, resolveErr
}
resolved = injectSignatureIntoBody(resolved, sigResult)
// Writing-path lint: AutoFix=true / Strict=false — the writing-path
@@ -364,7 +365,7 @@ func buildRawEMLForDraftCreate(
}
rawEML, buildErr := bld.BuildBase64URL()
if buildErr != nil {
return "", lintApplied, lintBlocked, mailValidationError("build EML failed: %v", buildErr).WithCause(buildErr)
return "", lintApplied, lintBlocked, output.ErrValidation("build EML failed: %v", buildErr)
}
return rawEML, lintApplied, lintBlocked, nil
}

View File

@@ -10,6 +10,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/ics"
@@ -87,7 +88,7 @@ var MailDraftEdit = common.Shortcut{
}
draftID := runtime.Str("draft-id")
if draftID == "" {
return mailValidationParamError("--draft-id", "--draft-id is required for real draft edits; if you only need a patch template, run with --print-patch-template")
return output.ErrValidation("--draft-id is required for real draft edits; if you only need a patch template, run with --print-patch-template")
}
mailboxID := resolveComposeMailboxID(runtime)
if runtime.Bool("inspect") {
@@ -99,11 +100,11 @@ var MailDraftEdit = common.Shortcut{
}
rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID)
if err != nil {
return mailDecorateProblemMessage(err, "read draft raw EML failed")
return fmt.Errorf("read draft raw EML failed: %w", err)
}
snapshot, err := draftpkg.Parse(rawDraft)
if err != nil {
return mailFailedPreconditionError("parse draft raw EML failed: %v", err).WithCause(err)
return output.ErrValidation("parse draft raw EML failed: %v", err)
}
// Pre-process ops that need snapshot context: resolve signature using
// the draft's From address, and build ICS for set_calendar using the
@@ -122,8 +123,8 @@ var MailDraftEdit = common.Shortcut{
// Going straight into PatchOp.Value would bypass emlbuilder's
// validateHeaderValue gate, so repeat the check here explicitly.
if err := validateHeaderAddress(draftFromEmail); err != nil {
return mailFailedPreconditionError(
"cannot set --request-receipt: draft From address is unsafe for a header (%v)", err).WithCause(err)
return output.ErrValidation(
"cannot set --request-receipt: draft From address is unsafe for a header (%v)", err)
}
patch.Ops = append(patch.Ops, draftpkg.PatchOp{
Op: "set_header",
@@ -146,11 +147,11 @@ var MailDraftEdit = common.Shortcut{
if calPart := draftpkg.FindPartByMediaType(snapshot.Body, "text/calendar"); calPart != nil {
parsed := ics.ParseEvent(string(calPart.Body))
if parsed == nil || !parsed.IsLarkDraft {
return mailFailedPreconditionError("set_calendar: calendar event has already been created and is read-only; use --remove-event to remove it, then --set-event-* to create a new one")
return output.ErrValidation("set_calendar: calendar event has already been created and is read-only; use --remove-event to remove it, then --set-event-* to create a new one")
}
}
if _, _, err := parseEventTimeRange(patch.Ops[i].EventStart, patch.Ops[i].EventEnd); err != nil {
return prefixEventRangeError("set_calendar: ", err)
return output.ErrValidation("set_calendar: %v", err)
}
// Derive effective To/Cc by replaying all pending recipient ops so
// the ICS ATTENDEE list matches the final post-edit recipients.
@@ -165,7 +166,7 @@ var MailDraftEdit = common.Shortcut{
joinAddresses(ccAddrs),
)
if calData == nil {
return mailValidationError("set_calendar: failed to build ICS from event fields")
return output.ErrValidation("set_calendar: failed to build ICS from event fields")
}
patch.Ops[i].CalendarICS = calData
}
@@ -205,16 +206,16 @@ var MailDraftEdit = common.Shortcut{
dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()}
if len(patch.Ops) > 0 {
if err := draftpkg.Apply(dctx, snapshot, patch); err != nil {
return mailValidationError("apply draft patch failed: %v", err).WithCause(err)
return output.ErrValidation("apply draft patch failed: %v", err)
}
}
serialized, err := draftpkg.Serialize(snapshot)
if err != nil {
return mailValidationError("serialize draft failed: %v", err).WithCause(err)
return output.ErrValidation("serialize draft failed: %v", err)
}
updateResult, err := draftpkg.UpdateWithRaw(runtime, mailboxID, draftID, serialized)
if err != nil {
return mailDecorateProblemMessage(err, "update draft failed")
return fmt.Errorf("update draft failed: %w", err)
}
projection := draftpkg.Project(snapshot)
out := map[string]interface{}{
@@ -269,11 +270,11 @@ var MailDraftEdit = common.Shortcut{
func executeDraftInspect(runtime *common.RuntimeContext, mailboxID, draftID string) error {
rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID)
if err != nil {
return mailDecorateProblemMessage(err, "read draft raw EML failed")
return fmt.Errorf("read draft raw EML failed: %w", err)
}
snapshot, err := draftpkg.Parse(rawDraft)
if err != nil {
return mailFailedPreconditionError("parse draft raw EML failed: %v", err).WithCause(err)
return output.ErrValidation("parse draft raw EML failed: %v", err)
}
projection := draftpkg.Project(snapshot)
out := map[string]interface{}{
@@ -421,12 +422,7 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
if bodyVal != "" {
for _, op := range patch.Ops {
if op.Op == "set_body" || op.Op == "set_reply_body" {
return patch, mailValidationError("--body / --body-file and --patch-file body ops (set_body/set_reply_body) are mutually exclusive; use one or the other").
WithParams(
mailInvalidParam("--body", "mutually exclusive with --patch-file body ops"),
mailInvalidParam("--body-file", "mutually exclusive with --patch-file body ops"),
mailInvalidParam("--patch-file", "mutually exclusive with direct body flags"),
)
return patch, output.ErrValidation("--body / --body-file and --patch-file body ops (set_body/set_reply_body) are mutually exclusive; use one or the other")
}
}
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "set_body", Value: bodyVal})
@@ -452,29 +448,20 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
hasEventSet := runtime.Str("set-event-summary") != ""
hasEventRemove := runtime.Bool("remove-event")
if !hasEventSet && (runtime.Str("set-event-start") != "" || runtime.Str("set-event-end") != "" || runtime.Str("set-event-location") != "") {
return patch, mailValidationParamError("--set-event-summary", "--set-event-start, --set-event-end, and --set-event-location require --set-event-summary")
return patch, output.ErrValidation("--set-event-start, --set-event-end, and --set-event-location require --set-event-summary")
}
if hasEventSet && hasEventRemove {
return patch, mailValidationError("--set-event-summary and --remove-event are mutually exclusive").
WithParams(
mailInvalidParam("--set-event-summary", "mutually exclusive with --remove-event"),
mailInvalidParam("--remove-event", "mutually exclusive with --set-event-summary"),
)
return patch, output.ErrValidation("--set-event-summary and --remove-event are mutually exclusive")
}
if hasEventSet {
summary := runtime.Str("set-event-summary")
start := runtime.Str("set-event-start")
end := runtime.Str("set-event-end")
if summary == "" || start == "" || end == "" {
return patch, mailValidationError("--set-event-summary, --set-event-start, and --set-event-end must all be provided together").
WithParams(
mailInvalidParam("--set-event-summary", "required with --set-event-start/--set-event-end"),
mailInvalidParam("--set-event-start", "required with --set-event-summary/--set-event-end"),
mailInvalidParam("--set-event-end", "required with --set-event-summary/--set-event-start"),
)
return patch, output.ErrValidation("--set-event-summary, --set-event-start, and --set-event-end must all be provided together")
}
if _, _, err := parseEventTimeRange(start, end); err != nil {
return patch, prefixEventRangeError("--set-event-", err)
return patch, output.ErrValidation("%s", prefixEventRangeError("--set-event-", err).Error())
}
patch.Ops = append(patch.Ops, draftpkg.PatchOp{
Op: "set_calendar",
@@ -488,7 +475,7 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
}
if len(patch.Ops) == 0 && !runtime.Bool("request-receipt") {
return patch, mailValidationError("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)")
return patch, output.ErrValidation("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)")
}
if len(patch.Ops) == 0 {
// --request-receipt only: Validate() would reject empty Ops, so skip it
@@ -496,10 +483,7 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
// the draft's From address is known.
return patch, nil
}
if err := patch.Validate(); err != nil {
return patch, mailValidationError("%v", err).WithCause(err)
}
return patch, nil
return patch, patch.Validate()
}
// loadPatchFile reads and JSON-decodes a patch file from a relative path
@@ -508,25 +492,19 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
// internal stack traces.
func loadPatchFile(runtime *common.RuntimeContext, path string) (draftpkg.Patch, error) {
var patch draftpkg.Patch
if err := runtime.ValidatePath(path); err != nil {
return patch, mailValidationParamError("--patch-file", "--patch-file %q: %v", path, err).WithCause(mailInputStatError(err))
}
f, err := runtime.FileIO().Open(path)
if err != nil {
return patch, mailValidationParamError("--patch-file", "--patch-file %q: %v", path, err).WithCause(mailInputStatError(err))
return patch, fmt.Errorf("--patch-file %q: %w", path, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return patch, mailValidationParamError("--patch-file", "read --patch-file %q: %v", path, err).WithCause(err)
return patch, err
}
if err := json.Unmarshal(data, &patch); err != nil {
return patch, mailValidationParamError("--patch-file", "parse patch file: %v", err).WithCause(err)
return patch, fmt.Errorf("parse patch file: %w", err)
}
if err := patch.Validate(); err != nil {
return patch, mailValidationParamError("--patch-file", "validate patch file: %v", err).WithCause(err)
}
return patch, nil
return patch, patch.Validate()
}
// buildDraftEditPatchTemplate returns the JSON template emitted by

View File

@@ -4,11 +4,8 @@
package mail
import (
"errors"
"os"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/spf13/cobra"
@@ -85,43 +82,6 @@ func TestBuildDraftEditPatch_InvalidPriority(t *testing.T) {
}
}
func TestLoadPatchFileRejectsUnsafePathWithTypedParam(t *testing.T) {
chdirTemp(t)
f, _, _, _ := mailShortcutTestFactory(t)
rt := &common.RuntimeContext{Cmd: &cobra.Command{Use: "test"}, Factory: f, Config: mailTestConfig()}
_, err := loadPatchFile(rt, "../patch.json")
if err == nil {
t.Fatal("expected unsafe patch path to fail")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--patch-file" {
t.Fatalf("param = %q, want --patch-file", validationErr.Param)
}
}
func TestLoadPatchFileValidateFailureKeepsPatchFileParam(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("patch.json", []byte(`{"ops":[]}`), 0o644); err != nil {
t.Fatal(err)
}
f, _, _, _ := mailShortcutTestFactory(t)
rt := &common.RuntimeContext{Cmd: &cobra.Command{Use: "test"}, Factory: f, Config: mailTestConfig()}
_, err := loadPatchFile(rt, "patch.json")
if err == nil {
t.Fatal("expected invalid patch file to fail")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--patch-file" {
t.Fatalf("param = %q, want --patch-file", validationErr.Param)
}
}
func TestBuildDraftEditPatch_NoPriority(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{"set-subject": "hello"})
patch, err := buildDraftEditPatch(rt)

View File

@@ -5,10 +5,11 @@ package mail
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -45,21 +46,11 @@ type failedDraft struct {
// "success_count": 2,
// "failure_count": 1,
// "sent": [{"draft_id":..., "message_id":..., "thread_id":...}, ...],
// "failed":[{"draft_id":..., "error":...}],
// "aborted": true,
// "abort_error": {"type":..., "subtype":..., "code":..., "message":..., "hint":...}
// "failed":[{"draft_id":..., "error":...}]
// }
//
// failed is marked omitempty so a fully successful batch returns a clean shape
// without an empty array.
//
// aborted reports an account-level abort: the failure repeats identically for
// every draft, so the remaining drafts were not attempted and retrying the
// batch as-is fails the same way. abort_error carries the typed error that
// triggered the abort (same wire shape as a stderr error envelope's error
// object) so callers can route recovery from stdout alone. A --stop-on-error
// stop does NOT set aborted: there the failure is draft-level and the caller
// chose to stop early.
type batchSendOutput struct {
MailboxID string `json:"mailbox_id"`
Total int `json:"total"`
@@ -67,8 +58,6 @@ type batchSendOutput struct {
FailureCount int `json:"failure_count"`
Sent []sentDraft `json:"sent"`
Failed []failedDraft `json:"failed,omitempty"`
Aborted bool `json:"aborted,omitempty"`
AbortError interface{} `json:"abort_error,omitempty"`
}
// MailDraftSend is the `+draft-send` shortcut: send N existing drafts
@@ -77,9 +66,9 @@ type batchSendOutput struct {
// drafts are user-owned resources and bot has no coherent semantics here.
//
// Output schema is the batchSendOutput type above. Partial failures (any
// failed[]) emit an ok:false multi-status envelope so that agents can
// distinguish "all sent" from "some sent" without parsing the success_count
// field.
// failed[]) return exit 1 with envelope.error.type="partial_failure" so that
// agents can distinguish "all sent" from "some sent" without parsing the
// success_count field.
var MailDraftSend = common.Shortcut{
Service: "mail",
Command: "+draft-send",
@@ -112,16 +101,14 @@ var MailDraftSend = common.Shortcut{
// 2. Validate the draft-id slice (non-empty, under MaxBatchSendDrafts cap,
// no empty elements).
// 3. Loop over each draft ID, calling POST .../drafts/:id/send directly via
// runtime.CallAPITyped. Per-draft outcomes:
// - fatal err (isFatalSendErr) → abort immediately (bypasses
// --stop-on-error): with earlier progress, emit the aborted ledger as the
// single failure result; with none, return the typed error directly.
// runtime.CallAPI. Per-draft outcomes:
// - fatal err (isFatalSendErr) → return immediately (bypasses --stop-on-error).
// - recoverable err → append to failed[]; honor --stop-on-error.
// - success + automation_send_disable signal → abort the same way with a
// failed-precondition error.
// - success + automation_send_disable signal → return immediately with
// ExitAPI/"automation_send_disabled".
// - success → append to sent[].
// 4. Emit batchSendOutput via runtime.Out.
// 5. If any draft failed, emit ok:false via runtime.OutPartialFailure.
// 5. If any draft failed, return ExitAPI/"partial_failure" so exit code = 1.
func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveComposeMailboxID(rt)
draftIDs, err := normalizedDraftSendIDs(rt)
@@ -135,9 +122,9 @@ func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
idx := i + 1
writeDraftSendProgressf(rt, "[%d/%d] sending draft %s",
idx, len(draftIDs), sanitizeForSingleLine(id))
// Direct CallAPITyped rather than draftpkg.Send: this shortcut never sends
// Direct CallAPI rather than draftpkg.Send: this shortcut never sends
// a body, so the helper's send_time-aware envelope would add no value.
data, err := rt.CallAPITyped("POST",
data, err := rt.CallAPI("POST",
mailboxPath(mailboxID, "drafts", id, "send"), nil, nil)
if err != nil {
if isFatalSendErr(err) {
@@ -145,15 +132,13 @@ func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
hadProgress := out.hasProgress()
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
if hadProgress {
emitDraftSendOutput(rt, &out)
}
// Account- / mailbox-level failures (auth, permission, network,
// quota) will repeat identically for every remaining draft —
// abort immediately so the caller sees a single clear error
// instead of 100 redundant failed[] entries. With earlier
// progress the aborted ledger is the single failure result;
// with none, stdout stays empty and the typed error envelope is.
if hadProgress {
return emitDraftSendAborted(rt, &out, err)
}
// instead of 100 redundant failed[] entries.
return err
}
writeDraftSendProgressf(rt, "[%d/%d] failed draft %s: %s",
@@ -165,19 +150,17 @@ func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
continue
}
if reason := extractAutomationDisabledReason(data); reason != "" {
err := mailFailedPreconditionError(
"automation send is disabled for this mailbox: %s", reason).
WithHint("enable automation send for this mailbox, or send the draft from the Lark client")
err := output.Errorf(output.ExitAPI, "automation_send_disabled",
"automation send is disabled for this mailbox: %s", reason)
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
// HTTP success (code: 0) but the backend signaled automation send
// is disabled — every subsequent send will fail the same way, so
// abort the batch with a single failure result: the aborted ledger
// when earlier drafts made progress, the typed error otherwise.
if out.hasProgress() {
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
return emitDraftSendAborted(rt, &out, err)
emitDraftSendOutput(rt, &out)
}
// HTTP success (code: 0) but the backend signaled automation send
// is disabled — every subsequent send will fail the same way, so
// abort the batch with a single descriptive error.
return err
}
s := sentDraft{DraftID: id}
@@ -196,11 +179,13 @@ func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
idx, len(draftIDs), sanitizeForSingleLine(id))
}
}
if len(out.Failed) == 0 {
emitDraftSendOutput(rt, &out)
emitDraftSendOutput(rt, &out)
if out.FailureCount == 0 {
return nil
}
return emitDraftSendPartialFailure(rt, &out)
return output.Errorf(output.ExitAPI, "partial_failure",
"%d of %d drafts failed to send", out.FailureCount, out.Total)
}
// dryRunDraftSend builds the --dry-run preview: one POST call per draft ID,
@@ -227,7 +212,7 @@ func normalizedDraftSendIDs(rt *common.RuntimeContext) ([]string, error) {
func normalizeDraftSendIDs(draftIDs []string) ([]string, error) {
if len(draftIDs) == 0 {
return nil, mailValidationParamError("--draft-id", "--draft-id is required")
return nil, output.ErrValidation("--draft-id is required")
}
normalized := make([]string, 0, len(draftIDs))
@@ -235,16 +220,16 @@ func normalizeDraftSendIDs(draftIDs []string) ([]string, error) {
for _, id := range draftIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
return nil, mailValidationParamError("--draft-id", "--draft-id contains empty value")
return nil, output.ErrValidation("--draft-id contains empty value")
}
if _, ok := seen[trimmed]; ok {
return nil, mailValidationParamError("--draft-id", "--draft-id contains duplicate value: %s", trimmed)
return nil, output.ErrValidation("--draft-id contains duplicate value: %s", trimmed)
}
seen[trimmed] = struct{}{}
normalized = append(normalized, trimmed)
}
if len(normalized) > MaxBatchSendDrafts {
return nil, mailValidationParamError("--draft-id",
return nil, output.ErrValidation(
"too many drafts: %d > %d (split into multiple batches)",
len(normalized), MaxBatchSendDrafts)
}
@@ -261,24 +246,6 @@ func emitDraftSendOutput(rt *common.RuntimeContext, out *batchSendOutput) {
rt.Out(*out, nil)
}
func emitDraftSendPartialFailure(rt *common.RuntimeContext, out *batchSendOutput) error {
out.SuccessCount = len(out.Sent)
out.FailureCount = len(out.Failed)
return rt.OutPartialFailure(*out, nil)
}
// emitDraftSendAborted emits the batch ledger as the single failure result for
// an account-level abort: the ledger carries aborted/abort_error and the
// returned partial-failure signal sets the exit code without a second error
// envelope on stderr.
func emitDraftSendAborted(rt *common.RuntimeContext, out *batchSendOutput, cause error) error {
out.Aborted = true
if typed, ok := errs.UnwrapTypedError(errs.WrapInternal(cause)); ok {
out.AbortError = typed
}
return emitDraftSendPartialFailure(rt, out)
}
func writeDraftSendProgressf(rt *common.RuntimeContext, format string, args ...interface{}) {
if rt == nil || rt.Factory == nil || rt.Factory.IOStreams == nil || rt.Factory.IOStreams.ErrOut == nil {
return
@@ -292,38 +259,52 @@ func writeDraftSendProgressf(rt *common.RuntimeContext, format string, args ...i
//
// Trigger conditions:
//
// - err does not expose a typed Problem:
// - err does not unwrap to an *output.ExitError, or its Detail is missing:
// unknown shapes are treated as fatal so they cannot accidentally
// accumulate into failed[] for every remaining draft.
// - Problem.Category ∈ {authentication, authorization, config, network,
// internal}: token, scope, app-installation problems, throttling,
// connectivity, SDK, and invalid-response failures are account-level.
// - Problem.Subtype ∈ {rate_limit, quota_exceeded}: throttling and quota
// exhaustion are account-level.
// - Problem.Code ∈ {1234013, 1236007, 1236008, 1236009, 1236010, 1236013}:
// mailbox missing / quota exhaustion is account-level. Mailbox-not-found
// stays code-scoped (1234013) rather than matching subtype not_found, so
// an unrelated not_found — e.g. a single bad draft ID — remains a
// per-draft recoverable failure.
// - Detail.Type ∈ {"auth", "app_status", "config", "permission",
// "rate_limit", "network"}: token, scope, app-installation problems,
// throttling, and connectivity are account-level.
// - Code == output.ExitNetwork: connectivity loss is account-level.
// - Detail.Code ∈ {LarkErrMailboxNotFound, LarkErrMailSendQuotaUser,
// LarkErrMailSendQuotaUserExt, LarkErrMailSendQuotaTenantExt,
// LarkErrMailQuota, LarkErrTenantStorageLimit}: mailbox / quota
// exhaustion is account-level.
func isFatalSendErr(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return true
}
switch p.Category {
case errs.CategoryAuthentication, errs.CategoryAuthorization, errs.CategoryConfig, errs.CategoryNetwork, errs.CategoryInternal:
switch exitErr.Detail.Type {
case "auth", "app_status", "config":
return true
case "permission", "rate_limit", "network":
return true
}
if p.Subtype == errs.SubtypeRateLimit || p.Subtype == errs.SubtypeQuotaExceeded {
if exitErr.Code == output.ExitNetwork || wrapsExitCode(err, output.ExitNetwork) {
return true
}
switch p.Code {
case 1234013, 1236007, 1236008, 1236009, 1236010, 1236013:
switch exitErr.Detail.Code {
case output.LarkErrMailboxNotFound,
output.LarkErrMailSendQuotaUser,
output.LarkErrMailSendQuotaUserExt,
output.LarkErrMailSendQuotaTenantExt,
output.LarkErrMailQuota,
output.LarkErrTenantStorageLimit:
return true
}
return false
}
func wrapsExitCode(err error, code int) bool {
for unwrapped := errors.Unwrap(err); unwrapped != nil; unwrapped = errors.Unwrap(unwrapped) {
if exitErr, ok := unwrapped.(*output.ExitError); ok && exitErr.Code == code {
return true
}
}
return false
}
// extractAutomationDisabledReason returns the human-readable reason when the
// send succeeded at HTTP level (code: 0) but the backend reports that
// automation send is disabled for this mailbox. An empty return value means

View File

@@ -4,7 +4,6 @@
package mail
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -13,7 +12,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -93,32 +91,6 @@ func stubDraftSend(reg *httpmock.Registry, draftID string, body map[string]inter
return stub
}
func decodeDraftSendPartialEnvelopeData(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
}
if envelope.OK {
t.Fatalf("expected ok:false partial-failure output, stdout=%s", stdout.String())
}
return envelope.Data
}
func assertPartialFailureSignal(t *testing.T, err error) {
t.Helper()
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
if pfErr.Code != output.ExitAPI {
t.Errorf("PartialFailureError.Code = %d, want ExitAPI=%d", pfErr.Code, output.ExitAPI)
}
}
// TestMailDraftSend_AllSuccess verifies the happy path: every draft sends
// successfully, sent[] is fully populated, failed[] is omitted from the JSON,
// and exit code = 0 (err == nil).
@@ -218,8 +190,7 @@ func TestMailDraftSend_ProgressWritesToStderr(t *testing.T) {
if strings.Contains(stdout.String(), "mail +draft-send:") {
t.Errorf("stdout must not contain progress lines; got %s", stdout.String())
}
assertPartialFailureSignal(t, err)
data := decodeDraftSendPartialEnvelopeData(t, stdout)
data := decodeShortcutEnvelopeData(t, stdout)
if data["success_count"].(float64) != 2 || data["failure_count"].(float64) != 1 {
t.Errorf("unexpected aggregate counts: %#v", data)
}
@@ -227,7 +198,7 @@ func TestMailDraftSend_ProgressWritesToStderr(t *testing.T) {
// TestMailDraftSend_PartialFailure verifies that one recoverable per-draft
// failure does not abort the batch; the remaining drafts are attempted; both
// arrays are populated; and the call returns the multi-status partial-failure signal.
// arrays are populated; and the call returns ExitAPI/"partial_failure".
func TestMailDraftSend_PartialFailure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
@@ -254,9 +225,18 @@ func TestMailDraftSend_PartialFailure(t *testing.T) {
if err == nil {
t.Fatal("expected partial_failure error, got nil")
}
assertPartialFailureSignal(t, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "partial_failure" {
t.Errorf("Detail.Type = %v, want partial_failure", exitErr.Detail)
}
data := decodeDraftSendPartialEnvelopeData(t, stdout)
data := decodeShortcutEnvelopeData(t, stdout)
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
@@ -304,8 +284,7 @@ func TestMailDraftSend_StopOnError(t *testing.T) {
t.Fatal("expected partial_failure error, got nil")
}
assertPartialFailureSignal(t, err)
data := decodeDraftSendPartialEnvelopeData(t, stdout)
data := decodeShortcutEnvelopeData(t, stdout)
if data["success_count"].(float64) != 1 {
t.Errorf("success_count = %v, want 1", data["success_count"])
}
@@ -315,14 +294,6 @@ func TestMailDraftSend_StopOnError(t *testing.T) {
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
// A --stop-on-error stop is a caller choice over a draft-level failure,
// not an account-level abort: the aborted/abort_error fields stay unset.
if _, present := data["aborted"]; present {
t.Errorf("aborted should be unset for --stop-on-error, got %v", data["aborted"])
}
if _, present := data["abort_error"]; present {
t.Errorf("abort_error should be unset for --stop-on-error, got %v", data["abort_error"])
}
}
// TestMailDraftSend_FatalAborts verifies that a fatal errno (mailbox not
@@ -344,12 +315,12 @@ func TestMailDraftSend_FatalAborts(t *testing.T) {
if err == nil {
t.Fatal("expected fatal abort error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if p.Code != output.LarkErrMailboxNotFound {
t.Errorf("expected code = %d, got %#v", output.LarkErrMailboxNotFound, p)
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailboxNotFound {
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailboxNotFound, exitErr.Detail)
}
// No JSON envelope on stdout because Execute returned early before rt.Out.
if stdout.Len() != 0 {
@@ -358,10 +329,9 @@ func TestMailDraftSend_FatalAborts(t *testing.T) {
}
// TestMailDraftSend_FatalAfterSuccessEmitsLedger verifies that a fatal error
// after earlier side effects emits the aborted stdout ledger as the single
// failure result: the returned partial-failure signal sets the exit code
// without a second error envelope, and abort_error carries the typed cause so
// callers can avoid blindly retrying a draft that was already sent.
// after earlier side effects still emits the aggregate stdout ledger before
// returning the fatal stderr error. This lets callers avoid blindly retrying a
// draft that was already sent.
func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
@@ -379,17 +349,17 @@ func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) {
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected partial-failure abort signal, got nil")
t.Fatal("expected fatal abort error, got nil")
}
// The ledger is the single failure result: the returned error must be the
// envelope-less partial-failure signal, not a typed error that the root
// dispatcher would render as a second failure envelope on stderr.
assertPartialFailureSignal(t, err)
if _, ok := errs.ProblemOf(err); ok {
t.Fatalf("abort signal must not carry a typed problem, got %v", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailSendQuotaUser {
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailSendQuotaUser, exitErr.Detail)
}
data := decodeDraftSendPartialEnvelopeData(t, stdout)
data := decodeShortcutEnvelopeData(t, stdout)
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
@@ -405,19 +375,6 @@ func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) {
if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" {
t.Errorf("failed[0].draft_id = %q, want d2", got)
}
if data["aborted"] != true {
t.Errorf("aborted = %v, want true", data["aborted"])
}
abortErr, ok := data["abort_error"].(map[string]interface{})
if !ok {
t.Fatalf("abort_error = %v, want object", data["abort_error"])
}
if abortErr["type"] != "api" {
t.Errorf("abort_error.type = %v, want api", abortErr["type"])
}
if abortErr["code"].(float64) != float64(output.LarkErrMailSendQuotaUser) {
t.Errorf("abort_error.code = %v, want %d", abortErr["code"], output.LarkErrMailSendQuotaUser)
}
}
// TestMailDraftSend_AutomationDisabled verifies that an HTTP-success response
@@ -445,22 +402,24 @@ func TestMailDraftSend_AutomationDisabled(t *testing.T) {
if err == nil {
t.Fatal("expected automation_send_disabled error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %#v, want validation/failed_precondition", p)
if exitErr.Code != output.ExitAPI {
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
}
if !strings.Contains(p.Message, "outbound automation disabled") {
t.Errorf("error message should propagate reason, got %q", p.Message)
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
}
if !strings.Contains(exitErr.Error(), "outbound automation disabled") {
t.Errorf("error message should propagate reason, got %q", exitErr.Error())
}
}
// TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger verifies that an
// automation-send policy stop after earlier successful sends emits the aborted
// batch ledger as the single failure result, with the failed-precondition
// cause carried in abort_error.
// automation-send policy stop after earlier successful sends still writes the
// batch ledger to stdout before returning the structured fatal error.
func TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
@@ -483,27 +442,17 @@ func TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger(t *testing.T) {
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected partial-failure abort signal, got nil")
t.Fatal("expected automation_send_disabled error, got nil")
}
assertPartialFailureSignal(t, err)
if _, ok := errs.ProblemOf(err); ok {
t.Fatalf("abort signal must not carry a typed problem, got %v", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
}
data := decodeDraftSendPartialEnvelopeData(t, stdout)
if data["aborted"] != true {
t.Errorf("aborted = %v, want true", data["aborted"])
}
abortErr, ok := data["abort_error"].(map[string]interface{})
if !ok {
t.Fatalf("abort_error = %v, want object", data["abort_error"])
}
if abortErr["type"] != "validation" || abortErr["subtype"] != "failed_precondition" {
t.Errorf("abort_error type/subtype = %v/%v, want validation/failed_precondition", abortErr["type"], abortErr["subtype"])
}
if msg, _ := abortErr["message"].(string); !strings.Contains(msg, "outbound automation disabled") {
t.Errorf("abort_error.message should carry the reason, got %q", msg)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
@@ -651,8 +600,12 @@ func TestMailDraftSend_MissingYes(t *testing.T) {
if err == nil {
t.Fatal("expected ExitConfirmationRequired, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
t.Errorf("Code = %d, want ExitConfirmationRequired=%d", code, output.ExitConfirmationRequired)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitConfirmationRequired {
t.Errorf("Code = %d, want ExitConfirmationRequired=%d", exitErr.Code, output.ExitConfirmationRequired)
}
}
@@ -835,58 +788,93 @@ func TestIsFatalSendErr(t *testing.T) {
want: true,
},
{
name: "internal typed fallback → fatal",
err: errs.NewInternalError(errs.SubtypeSDKError, "unexpected shape"),
name: "ExitError without Detail → fatal",
err: &output.ExitError{Code: output.ExitInternal},
want: true,
},
{
name: "authentication → fatal",
err: errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired"),
name: "auth → fatal",
err: &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{Type: "auth", Message: "token expired"},
},
want: true,
},
{
name: "authorization → fatal",
err: errs.NewPermissionError(errs.SubtypePermissionDenied, "denied"),
name: "app_status → fatal",
err: &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{Type: "app_status", Message: "app disabled"},
},
want: true,
},
{
name: "config → fatal",
err: errs.NewConfigError(errs.SubtypeInvalidConfig, "bad app_id"),
err: &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{Type: "config", Message: "bad app_id"},
},
want: true,
},
{
name: "network → fatal",
err: errs.NewNetworkError(errs.SubtypeNetworkTransport, "DNS timeout"),
name: "permission → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "permission", Message: "denied"},
},
want: true,
},
{
name: "rate_limit → fatal",
err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limited").WithCode(output.LarkErrRateLimit),
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "rate_limit", Code: output.LarkErrRateLimit},
},
want: true,
},
{
name: "ExitNetwork → fatal",
err: &output.ExitError{
Code: output.ExitNetwork,
Detail: &output.ErrDetail{Type: "network", Message: "DNS timeout"},
},
want: true,
},
{
name: "wrapped ExitNetwork → fatal",
err: output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", output.ErrNetwork("DNS timeout")),
want: true,
},
{
name: "LarkErrMailboxNotFound → fatal",
err: errs.NewAPIError(errs.SubtypeNotFound, "mailbox not found").WithCode(output.LarkErrMailboxNotFound),
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailboxNotFound},
},
want: true,
},
{
name: "LarkErrMailSendQuotaUser → fatal",
err: errs.NewAPIError(errs.SubtypeQuotaExceeded, "user daily send count exceeded").WithCode(output.LarkErrMailSendQuotaUser),
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailSendQuotaUser},
},
want: true,
},
{
name: "LarkErrTenantStorageLimit → fatal",
err: errs.NewAPIError(errs.SubtypeQuotaExceeded, "tenant storage limit").WithCode(output.LarkErrTenantStorageLimit),
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrTenantStorageLimit},
},
want: true,
},
{
name: "generic api unknown → recoverable",
err: errs.NewAPIError(errs.SubtypeUnknown, "draft not found").WithCode(230001),
want: false,
},
{
name: "not_found without account-level code → recoverable",
err: errs.NewAPIError(errs.SubtypeNotFound, "draft not found").WithCode(230002),
name: "generic api_error → recoverable",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: 230001},
},
want: false,
},
}

View File

@@ -1,76 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
func mailValidationError(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func mailValidationParamError(param, format string, args ...any) *errs.ValidationError {
return mailValidationError(format, args...).WithParam(param)
}
func mailInvalidParam(name, reason string) errs.InvalidParam {
return errs.InvalidParam{Name: name, Reason: reason}
}
func mailFailedPreconditionError(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...)
}
func mailInvalidResponseError(format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
}
func mailFileIOError(format string, err error, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
}
func mailInputStatError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return mailValidationError("unsafe file path: %s", err).WithCause(err)
}
return mailValidationError("cannot read file: %s", err).WithCause(err)
}
func mailDecorateProblemMessage(err error, format string, args ...any) error {
if err == nil {
return nil
}
prefix := fmt.Sprintf(format, args...)
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(prefix) != "" {
p.Message = prefix + ": " + p.Message
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %s", prefix, err.Error()).WithCause(err)
}
func mailAppendProblemHint(err error, hint string) error {
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "; " + hint
} else {
p.Hint = hint
}
return err
}
return errs.NewAPIError(errs.SubtypeUnknown, "%s", err.Error()).WithHint("%s", hint).WithCause(err)
}

View File

@@ -1,307 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"errors"
"io"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
func TestMailFileIOErrorTyped(t *testing.T) {
cause := errors.New("disk read failed")
err := mailFileIOError("load %s: %v", cause, "body.html", cause)
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected internal error, got %T", err)
}
if !errors.Is(err, cause) {
t.Fatalf("cause not preserved: %v", err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Subtype != errs.SubtypeFileIO {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeFileIO)
}
if !strings.Contains(p.Message, "body.html") || !strings.Contains(p.Message, "disk read failed") {
t.Fatalf("message missing context: %q", p.Message)
}
}
func TestMailFileIOErrorDoesNotAppendCauseAsFormatArg(t *testing.T) {
cause := errors.New("mkdir denied")
err := mailFileIOError("cannot create output directory %q", cause, "out")
if !errors.Is(err, cause) {
t.Fatalf("cause not preserved: %v", err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if strings.Contains(p.Message, "%!(") {
t.Fatalf("message contains fmt extra marker: %q", p.Message)
}
if strings.Contains(p.Message, "mkdir denied") {
t.Fatalf("cause should not be implicitly appended to message: %q", p.Message)
}
}
func TestMailInputStatErrorTyped(t *testing.T) {
if err := mailInputStatError(nil); err != nil {
t.Fatalf("nil input should stay nil, got %v", err)
}
pathErr := fileio.ErrPathValidation
err := mailInputStatError(pathErr)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T", err)
}
if !errors.Is(err, pathErr) {
t.Fatalf("cause not preserved: %v", err)
}
if !strings.Contains(err.Error(), "unsafe file path") {
t.Fatalf("unexpected path validation message: %v", err)
}
statErr := errors.New("permission denied")
err = mailInputStatError(statErr)
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T", err)
}
if !errors.Is(err, statErr) {
t.Fatalf("stat cause not preserved: %v", err)
}
if !strings.Contains(err.Error(), "cannot read file") {
t.Fatalf("unexpected stat message: %v", err)
}
}
func TestMailDecorateProblemMessageTypedAndPlain(t *testing.T) {
if err := mailDecorateProblemMessage(nil, "fetch profile"); err != nil {
t.Fatalf("nil input should stay nil, got %v", err)
}
typedErr := errs.NewAPIError(errs.SubtypeRateLimit, "too many requests")
err := mailDecorateProblemMessage(typedErr, "fetch %s", "profile")
if err != typedErr {
t.Fatalf("typed error should be decorated in place")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Message != "fetch profile: too many requests" {
t.Fatalf("message = %q", p.Message)
}
blankPrefixErr := errs.NewAPIError(errs.SubtypeUnknown, "unchanged")
err = mailDecorateProblemMessage(blankPrefixErr, " ")
p, ok = errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Message != "unchanged" {
t.Fatalf("blank prefix should not change message, got %q", p.Message)
}
plainCause := errors.New("sdk failed")
err = mailDecorateProblemMessage(plainCause, "fetch mailbox")
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("plain error should be upgraded to internal SDK error, got %T", err)
}
if !errors.Is(err, plainCause) {
t.Fatalf("cause not preserved: %v", err)
}
p, ok = errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Subtype != errs.SubtypeSDKError || !strings.Contains(p.Message, "fetch mailbox: sdk failed") {
t.Fatalf("unexpected problem: %+v", p)
}
}
func TestMailAppendProblemHintTypedAndPlain(t *testing.T) {
if err := mailAppendProblemHint(nil, "retry later"); err != nil {
t.Fatalf("nil input should stay nil, got %v", err)
}
withoutHint := errs.NewAPIError(errs.SubtypeUnknown, "failed")
err := mailAppendProblemHint(withoutHint, "retry later")
if err != withoutHint {
t.Fatalf("typed error should be updated in place")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Hint != "retry later" {
t.Fatalf("hint = %q", p.Hint)
}
withHint := errs.NewAPIError(errs.SubtypeUnknown, "failed").WithHint("check scope")
err = mailAppendProblemHint(withHint, "retry later")
p, ok = errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Hint != "check scope; retry later" {
t.Fatalf("hint = %q", p.Hint)
}
plainCause := errors.New("legacy api failed")
err = mailAppendProblemHint(plainCause, "retry later")
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("plain error should be upgraded to API error, got %T", err)
}
if !errors.Is(err, plainCause) {
t.Fatalf("cause not preserved: %v", err)
}
p, ok = errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Hint != "retry later" || p.Subtype != errs.SubtypeUnknown {
t.Fatalf("unexpected problem: %+v", p)
}
}
func TestValidateBodyFileMutexTypedErrors(t *testing.T) {
err := validateBodyFileMutex("<p>Hello</p>", "body.html", func(string) error { return nil })
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T", err)
}
if len(validationErr.Params) != 2 {
t.Fatalf("params = %#v, want two conflicting params", validationErr.Params)
}
if validationErr.Params[0].Name != "--body" || validationErr.Params[1].Name != "--body-file" {
t.Fatalf("unexpected params: %#v", validationErr.Params)
}
pathErr := errors.New("outside cwd")
err = validateBodyFileMutex("", "body.html", func(string) error { return pathErr })
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T", err)
}
if validationErr.Param != "--body-file" {
t.Fatalf("param = %q, want --body-file", validationErr.Param)
}
if !errors.Is(err, pathErr) {
t.Fatalf("cause not preserved: %v", err)
}
}
func TestReadBodyFileTypedErrors(t *testing.T) {
openErr := errors.New("missing")
_, err := readBodyFile(bodyFileTestIO{
open: func(string) (fileio.File, error) { return nil, openErr },
}, "missing.html")
requireBodyFileValidationError(t, err, openErr)
if !strings.Contains(err.Error(), "open --body-file missing.html") {
t.Fatalf("unexpected open message: %v", err)
}
readErr := errors.New("read broken")
_, err = readBodyFile(bodyFileTestIO{
open: func(string) (fileio.File, error) {
return &bodyFileTestFile{readErr: readErr}, nil
},
}, "body.html")
requireBodyFileValidationError(t, err, readErr)
if !strings.Contains(err.Error(), "read --body-file body.html") {
t.Fatalf("unexpected read message: %v", err)
}
_, err = readBodyFile(bodyFileTestIO{
open: func(string) (fileio.File, error) {
return &bodyFileTestFile{remaining: maxBodyFileSize + 1}, nil
},
}, "huge.html")
requireBodyFileValidationError(t, err, nil)
if !strings.Contains(err.Error(), "file exceeds 32 MB limit") {
t.Fatalf("unexpected size message: %v", err)
}
}
func requireBodyFileValidationError(t *testing.T, err error, cause error) {
t.Helper()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T (%v)", err, err)
}
if validationErr.Param != "--body-file" {
t.Fatalf("param = %q, want --body-file", validationErr.Param)
}
if cause != nil && !errors.Is(err, cause) {
t.Fatalf("cause %v not preserved in %v", cause, err)
}
}
type bodyFileTestIO struct {
open func(string) (fileio.File, error)
}
func (fio bodyFileTestIO) Open(name string) (fileio.File, error) {
return fio.open(name)
}
func (fio bodyFileTestIO) Stat(string) (fileio.FileInfo, error) {
return nil, errors.New("unused")
}
func (fio bodyFileTestIO) ResolvePath(path string) (string, error) {
return path, nil
}
func (fio bodyFileTestIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
return nil, errors.New("unused")
}
type bodyFileTestFile struct {
readErr error
remaining int
}
func (f *bodyFileTestFile) Read(p []byte) (int, error) {
if f.readErr != nil {
return 0, f.readErr
}
if f.remaining <= 0 {
return 0, io.EOF
}
n := len(p)
if n > f.remaining {
n = f.remaining
}
for i := range p[:n] {
p[i] = 'x'
}
f.remaining -= n
return n, nil
}
func (f *bodyFileTestFile) ReadAt([]byte, int64) (int, error) {
return 0, errors.New("unused")
}
func (f *bodyFileTestFile) Close() error {
return nil
}
var _ fileio.FileIO = bodyFileTestIO{}
var _ fileio.File = (*bodyFileTestFile)(nil)

View File

@@ -10,7 +10,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -135,10 +135,10 @@ var MailForward = common.Shortcut{
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return mailDecorateProblemMessage(err, "failed to fetch original message")
return fmt.Errorf("failed to fetch original message: %w", err)
}
if err := validateForwardAttachmentURLs(sourceMsg); err != nil {
return mailDecorateProblemMessage(err, "forward blocked")
return fmt.Errorf("forward blocked: %w", err)
}
orig := sourceMsg.Original
@@ -243,7 +243,7 @@ var MailForward = common.Shortcut{
}
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return mailValidationParamError("--inline", "--inline requires HTML mode, but neither the new body nor the original message contains HTML")
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
if err != nil {
@@ -257,7 +257,7 @@ var MailForward = common.Shortcut{
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if useHTML {
if err := validateInlineImageURLs(sourceMsg); err != nil {
return mailDecorateProblemMessage(err, "forward blocked")
return fmt.Errorf("forward blocked: %w", err)
}
processedBody := buildBodyDiv(body, bodyIsHTML(body))
origLargeAttCard := stripLargeAttachmentCard(&orig)
@@ -274,7 +274,7 @@ var MailForward = common.Shortcut{
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(processedBody)
if resolveErr != nil {
return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
return resolveErr
}
bodyWithSig := resolved
if sigResult != nil {
@@ -347,7 +347,7 @@ var MailForward = common.Shortcut{
}
content, err := downloadAttachmentContent(runtime, att.DownloadURL)
if err != nil {
return mailDecorateProblemMessage(err, "failed to download original attachment %s", att.Filename)
return fmt.Errorf("failed to download original attachment %s: %w", att.Filename, err)
}
contentType := att.ContentType
if contentType == "" {
@@ -381,13 +381,13 @@ var MailForward = common.Shortcut{
}
for _, f := range userFiles {
if f.Size > MaxLargeAttachmentSize {
return mailFailedPreconditionError("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
return output.ErrValidation("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
f.FileName, float64(f.Size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024)
}
}
totalCount := len(origAtts) + len(largeAttIDs) + len(userFiles)
if totalCount > MaxAttachmentCount {
return mailFailedPreconditionError("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount)
return output.ErrValidation("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount)
}
allFiles = append(allFiles, userFiles...)
classified := classifyAttachments(allFiles, emlBase)
@@ -413,7 +413,7 @@ var MailForward = common.Shortcut{
// Upload oversized attachments as large attachments.
if len(classified.Oversized) > 0 {
if composedHTMLBody == "" && composedTextBody == "" {
return mailFailedPreconditionError("large attachments require a body; " +
return output.ErrValidation("large attachments require a body; " +
"empty messages cannot include the download link")
}
if runtime.Config == nil || runtime.UserOpenId() == "" {
@@ -421,7 +421,7 @@ var MailForward = common.Shortcut{
for _, f := range classified.Oversized {
totalBytes += f.Size
}
return mailFailedPreconditionError("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
return output.ErrValidation("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
"large attachment upload requires user identity (--as user)",
float64(totalBytes)/1024/1024)
}
@@ -486,18 +486,18 @@ var MailForward = common.Shortcut{
if len(mergedLargeAttIDs) > 0 {
idsJSON, err := json.Marshal(mergedLargeAttIDs)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to encode large attachment IDs: %v", err).WithCause(err)
return fmt.Errorf("failed to encode large attachment IDs: %w", err)
}
bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, base64.StdEncoding.EncodeToString(idsJSON))
}
rawEML, err := bld.BuildBase64URL()
if err != nil {
return mailValidationError("failed to build EML: %v", err).WithCause(err)
return fmt.Errorf("failed to build EML: %w", err)
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return mailDecorateProblemMessage(err, "failed to create draft")
return fmt.Errorf("failed to create draft: %w", err)
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
@@ -510,7 +510,7 @@ var MailForward = common.Shortcut{
}
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
if err != nil {
return mailDecorateProblemMessage(err, "failed to send forward (draft %s created but not sent)", draftResult.DraftID)
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftResult.DraftID, err)
}
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/mail/lint"
@@ -55,18 +54,10 @@ var MailLintHTML = common.Shortcut{
// Mutual exclusion + exactly-one-of validation for --body / --body-file.
bodyEmpty := strings.TrimSpace(body) == ""
if bodyEmpty && bodyFile == "" {
return mailValidationError("exactly one of --body or --body-file is required").
WithParams(
mailInvalidParam("--body", "required when --body-file is empty"),
mailInvalidParam("--body-file", "required when --body is empty"),
)
return output.ErrValidation("exactly one of --body or --body-file is required")
}
if !bodyEmpty && bodyFile != "" {
return mailValidationError("--body and --body-file are mutually exclusive; pass exactly one").
WithParams(
mailInvalidParam("--body", "mutually exclusive with --body-file"),
mailInvalidParam("--body-file", "mutually exclusive with --body"),
)
return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one")
}
// --body-file safety: cwd-subtree only. Mirrors the existing pattern
@@ -74,7 +65,7 @@ var MailLintHTML = common.Shortcut{
// runtime.ValidatePath.
if bodyFile != "" {
if err := runtime.ValidatePath(bodyFile); err != nil {
return mailValidationParamError("--body-file", "--body-file: %v", err).WithCause(err)
return output.ErrValidation("--body-file: %v", err)
}
}
@@ -150,7 +141,7 @@ func readLintHTMLBody(runtime *common.RuntimeContext) (string, error) {
path := strings.TrimSpace(runtime.Str("body-file"))
if path == "" {
// Should be unreachable given Validate, but defensive.
return "", errs.NewInternalError(errs.SubtypeUnknown, "internal: --body-file empty after Validate")
return "", output.ErrValidation("internal: --body-file empty after Validate")
}
return readBodyFile(runtime.FileIO(), path)
}

View File

@@ -5,6 +5,7 @@ package mail
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,7 +48,7 @@ var MailMessage = common.Shortcut{
msg, err := fetchFullMessage(runtime, mailboxID, messageID, html)
if err != nil {
return mailDecorateProblemMessage(err, "failed to fetch email")
return fmt.Errorf("failed to fetch email: %w", err)
}
out := buildMessageOutput(msg, html)

View File

@@ -6,6 +6,7 @@ package mail
import (
"context"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -18,8 +19,7 @@ type mailMessagesOutput struct {
}
// MailMessages is the `+messages` shortcut: batch-fetch full content for
// multiple message IDs, chunking backend calls into batches of 20 while
// preserving request order.
// up to 20 message IDs in a single call, preserving request order.
var MailMessages = common.Shortcut{
Service: "mail",
Command: "+messages",
@@ -35,15 +35,11 @@ var MailMessages = common.Shortcut{
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateBotMailboxNotMe(runtime); err != nil {
return err
}
_, err := validateMessageIDs(runtime.Str("message-ids"))
return err
return validateBotMailboxNotMe(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(runtime)
messageIDs, _ := validateMessageIDs(runtime.Str("message-ids"))
messageIDs := splitByComma(runtime.Str("message-ids"))
body := map[string]interface{}{
"format": messageGetFormat(runtime.Bool("html")),
"message_ids": []string{"<message_id_1>", "<message_id_2>"},
@@ -63,9 +59,9 @@ var MailMessages = common.Shortcut{
}
mailboxID := resolveMailboxID(runtime)
hintIdentityFirst(runtime, mailboxID)
messageIDs, err := validateMessageIDs(runtime.Str("message-ids"))
if err != nil {
return err
messageIDs := splitByComma(runtime.Str("message-ids"))
if len(messageIDs) == 0 {
return output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
}
html := runtime.Bool("html")

View File

@@ -1,92 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/base64"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestMailMessagesExecuteChunksMoreThanTwentyIDs(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
ids := make([]string, 21)
for i := range ids {
ids[i] = base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("biz-%03d", i)))
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
BodyFilter: requestMessageIDsEqual(ids[:20]),
Body: batchGetMessagesResponse(ids[:20]),
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
BodyFilter: requestMessageIDsEqual(ids[20:]),
Body: batchGetMessagesResponse(ids[20:]),
})
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--message-ids", strings.Join(ids, ","),
}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := decodeShortcutEnvelopeData(t, stdout)
if got := int(out["total"].(float64)); got != len(ids) {
t.Fatalf("total = %d, want %d; stdout=%s", got, len(ids), stdout.String())
}
messages, ok := out["messages"].([]interface{})
if !ok {
t.Fatalf("messages has unexpected type %T", out["messages"])
}
if len(messages) != len(ids) {
t.Fatalf("messages length = %d, want %d", len(messages), len(ids))
}
for i, item := range messages {
msg, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("messages[%d] has unexpected type %T", i, item)
}
if got := msg["message_id"]; got != ids[i] {
t.Fatalf("messages[%d].message_id = %v, want %s", i, got, ids[i])
}
}
}
func requestMessageIDsEqual(want []string) func([]byte) bool {
return func(body []byte) bool {
var payload struct {
MessageIDs []string `json:"message_ids"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return false
}
return reflect.DeepEqual(payload.MessageIDs, want)
}
}
func batchGetMessagesResponse(ids []string) map[string]interface{} {
messages := make([]map[string]interface{}, 0, len(ids))
for _, id := range ids {
messages = append(messages, map[string]interface{}{
"message_id": id,
"subject": id,
})
}
return map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"messages": messages,
},
}
}

View File

@@ -137,7 +137,7 @@ var MailReply = common.Shortcut{
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return mailDecorateProblemMessage(err, "failed to fetch original message")
return fmt.Errorf("failed to fetch original message: %w", err)
}
orig := sourceMsg.Original
stripLargeAttachmentCard(&orig)
@@ -213,7 +213,7 @@ var MailReply = common.Shortcut{
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return mailValidationParamError("--inline", "--inline requires HTML mode, but neither the new body nor the original message contains HTML")
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
var bodyStr string
if useHTML {
@@ -264,7 +264,7 @@ var MailReply = common.Shortcut{
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if useHTML {
if err := validateInlineImageURLs(sourceMsg); err != nil {
return mailDecorateProblemMessage(err, "HTML reply blocked")
return fmt.Errorf("HTML reply blocked: %w", err)
}
var srcCIDs []string
bld, srcCIDs, srcInlineBytes, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
@@ -273,7 +273,7 @@ var MailReply = common.Shortcut{
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr)
if resolveErr != nil {
return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
return resolveErr
}
bodyWithSig := resolved
if sigResult != nil {
@@ -336,12 +336,12 @@ var MailReply = common.Shortcut{
}
rawEML, err := bld.BuildBase64URL()
if err != nil {
return mailValidationError("failed to build EML: %v", err).WithCause(err)
return fmt.Errorf("failed to build EML: %w", err)
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return mailDecorateProblemMessage(err, "failed to create draft")
return fmt.Errorf("failed to create draft: %w", err)
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
@@ -354,7 +354,7 @@ var MailReply = common.Shortcut{
}
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
if err != nil {
return mailDecorateProblemMessage(err, "failed to send reply (draft %s created but not sent)", draftResult.DraftID)
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftResult.DraftID, err)
}
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)

View File

@@ -139,7 +139,7 @@ var MailReplyAll = common.Shortcut{
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return mailDecorateProblemMessage(err, "failed to fetch original message")
return fmt.Errorf("failed to fetch original message: %w", err)
}
orig := sourceMsg.Original
stripLargeAttachmentCard(&orig)
@@ -226,7 +226,7 @@ var MailReplyAll = common.Shortcut{
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return mailValidationParamError("--inline", "--inline requires HTML mode, but neither the new body nor the original message contains HTML")
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
var bodyStr string
if useHTML {
@@ -271,7 +271,7 @@ var MailReplyAll = common.Shortcut{
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if useHTML {
if err := validateInlineImageURLs(sourceMsg); err != nil {
return mailDecorateProblemMessage(err, "HTML reply-all blocked")
return fmt.Errorf("HTML reply-all blocked: %w", err)
}
var srcCIDs []string
bld, srcCIDs, srcInlineBytes, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
@@ -280,7 +280,7 @@ var MailReplyAll = common.Shortcut{
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr)
if resolveErr != nil {
return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
return resolveErr
}
bodyWithSig := resolved
if sigResult != nil {
@@ -341,12 +341,12 @@ var MailReplyAll = common.Shortcut{
}
rawEML, err := bld.BuildBase64URL()
if err != nil {
return mailValidationError("failed to build EML: %v", err).WithCause(err)
return fmt.Errorf("failed to build EML: %w", err)
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return mailDecorateProblemMessage(err, "failed to create draft")
return fmt.Errorf("failed to create draft: %w", err)
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
@@ -359,7 +359,7 @@ var MailReplyAll = common.Shortcut{
}
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
if err != nil {
return mailDecorateProblemMessage(err, "failed to send reply-all (draft %s created but not sent)", draftResult.DraftID)
return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftResult.DraftID, err)
}
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -82,7 +83,7 @@ var MailSend = common.Shortcut{
return err
}
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
return mailValidationParamError("--subject", "--subject is required; pass the final email subject (or use --template-id)")
return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)")
}
// With --template-id, tos/ccs/bccs may come from the template, so
// defer the at-least-one-recipient check to Execute (after
@@ -240,7 +241,7 @@ var MailSend = common.Shortcut{
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
if resolveErr != nil {
return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
return resolveErr
}
resolved = injectSignatureIntoBody(resolved, sigResult)
// Writing-path lint: AutoFix=true / Strict=false — the writing-path
@@ -307,12 +308,12 @@ var MailSend = common.Shortcut{
rawEML, err := bld.BuildBase64URL()
if err != nil {
return mailValidationError("failed to build EML: %v", err).WithCause(err)
return fmt.Errorf("failed to build EML: %w", err)
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return mailDecorateProblemMessage(err, "failed to create draft")
return fmt.Errorf("failed to create draft: %w", err)
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
@@ -325,7 +326,7 @@ var MailSend = common.Shortcut{
}
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
if err != nil {
return mailDecorateProblemMessage(err, "failed to send email (draft %s created but not sent)", draftResult.DraftID)
return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftResult.DraftID, err)
}
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)

View File

@@ -113,11 +113,10 @@ var MailSendReceipt = common.Shortcut{
msg, err := fetchFullMessage(runtime, mailboxID, messageID, false)
if err != nil {
return mailDecorateProblemMessage(err, "failed to fetch original message")
return fmt.Errorf("failed to fetch original message: %w", err)
}
if !hasReadReceiptRequestLabel(msg) {
return mailFailedPreconditionError("message %s did not request a read receipt (no %s label); refusing to send receipt", messageID, readReceiptRequestLabel).
WithHint("only run +send-receipt for incoming messages that carry the READ_RECEIPT_REQUEST label")
return fmt.Errorf("message %s did not request a read receipt (no %s label); refusing to send receipt", messageID, readReceiptRequestLabel)
}
origSubject := strVal(msg["subject"])
@@ -127,12 +126,12 @@ var MailSendReceipt = common.Shortcut{
origSendMillis := parseInternalDateMillis(msg["internal_date"])
if origFromEmail == "" {
return mailFailedPreconditionError("original message %s has no sender address; cannot address receipt", messageID)
return fmt.Errorf("original message %s has no sender address; cannot address receipt", messageID)
}
senderEmail := resolveComposeSenderEmail(runtime)
if senderEmail == "" {
return mailValidationParamError("--from", "unable to determine sender email; please specify --from explicitly")
return fmt.Errorf("unable to determine sender email; please specify --from explicitly")
}
lang := detectSubjectLang(origSubject)
@@ -159,16 +158,16 @@ var MailSendReceipt = common.Shortcut{
rawEML, err := bld.BuildBase64URL()
if err != nil {
return mailValidationError("failed to build receipt EML: %v", err).WithCause(err)
return fmt.Errorf("failed to build receipt EML: %w", err)
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return mailDecorateProblemMessage(err, "failed to create receipt draft")
return fmt.Errorf("failed to create receipt draft: %w", err)
}
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, "")
if err != nil {
return mailDecorateProblemMessage(err, "failed to send receipt (draft %s created but not sent)", draftResult.DraftID)
return fmt.Errorf("failed to send receipt (draft %s created but not sent): %w", draftResult.DraftID, err)
}
out := buildDraftSendOutput(resData, mailboxID)

View File

@@ -5,7 +5,9 @@ package mail
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -64,22 +66,14 @@ var MailShareToChat = common.Shortcut{
msgID := runtime.Str("message-id")
threadID := runtime.Str("thread-id")
if msgID == "" && threadID == "" {
return mailValidationError("either --message-id or --thread-id is required").
WithParams(
mailInvalidParam("--message-id", "required when --thread-id is empty"),
mailInvalidParam("--thread-id", "required when --message-id is empty"),
)
return output.ErrValidation("either --message-id or --thread-id is required")
}
if msgID != "" && threadID != "" {
return mailValidationError("--message-id and --thread-id are mutually exclusive").
WithParams(
mailInvalidParam("--message-id", "mutually exclusive with --thread-id"),
mailInvalidParam("--thread-id", "mutually exclusive with --message-id"),
)
return output.ErrValidation("--message-id and --thread-id are mutually exclusive")
}
idType := runtime.Str("receive-id-type")
if !validReceiveIDTypes[idType] {
return mailValidationParamError("--receive-id-type", "--receive-id-type must be one of: chat_id, open_id, user_id, union_id, email")
return output.ErrValidation("--receive-id-type must be one of: chat_id, open_id, user_id, union_id, email")
}
return nil
},
@@ -96,23 +90,23 @@ var MailShareToChat = common.Shortcut{
} else {
createBody = map[string]interface{}{"message_id": msgID}
}
createResp, err := runtime.CallAPITyped("POST",
createResp, err := runtime.CallAPI("POST",
mailboxPath(mailboxID, "messages", "share_token"),
nil, createBody)
if err != nil {
return mailDecorateProblemMessage(err, "create share token")
return fmt.Errorf("create share token: %w", err)
}
cardID, _ := createResp["card_id"].(string)
if cardID == "" {
return mailInvalidResponseError("create share token: response missing card_id")
return fmt.Errorf("create share token: response missing card_id")
}
sendResp, err := runtime.CallAPITyped("POST",
sendResp, err := runtime.CallAPI("POST",
mailboxPath(mailboxID, "share_tokens", cardID, "send"),
map[string]interface{}{"receive_id_type": receiveIDType},
map[string]interface{}{"receive_id": receiveID})
if err != nil {
return mailDecorateProblemMessage(err, "share token created (card_id=%s) but send failed", cardID)
return fmt.Errorf("share token created (card_id=%s) but send failed: %w", cardID, err)
}
runtime.Out(map[string]interface{}{

View File

@@ -4,7 +4,6 @@
package mail
import (
"encoding/base64"
"os"
"strings"
"testing"
@@ -16,13 +15,16 @@ import (
// assertValidationError fails the test unless err carries the validation
// category with ExitValidation exit code and a message containing wantSubstr.
// Mail-produced validation errors should be typed; the exit-code fallback keeps
// shared framework validation gates covered without asserting their shape here.
// Accepts both typed *errs.ValidationError and legacy *output.ExitError so
// the helper survives the error-contract migration.
func assertValidationError(t *testing.T, err error, wantSubstr string) {
t.Helper()
if err == nil {
t.Fatal("expected a validation error, got nil")
}
// Accept both typed *errs.ValidationError and legacy *output.ExitError —
// the helper's purpose is to assert "this is a validation-category
// error" via either contract, so the dual-path matches the docstring.
code := output.ExitCodeOf(err)
if !errs.IsValidation(err) && code != output.ExitValidation {
t.Fatalf("expected a validation-category error, got %T: %v", err, err)
@@ -131,7 +133,7 @@ func TestMailMessageUserMailboxMePassesValidation(t *testing.T) {
func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--as", "bot", "--message-ids", validMessageIDForTest("biz-x"),
"+messages", "--as", "bot", "--message-ids", "msg_xxx",
}, f, stdout)
assertValidationError(t, err, "does not support --mailbox me")
}
@@ -140,7 +142,7 @@ func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
func TestMailMessagesBotExplicitMailboxPassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", validMessageIDForTest("biz-x"),
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", "msg_xxx",
}, f, stdout)
assertValidatePasses(t, err)
}
@@ -180,98 +182,3 @@ func TestMailTriageBotExplicitMailboxPassesValidation(t *testing.T) {
}, f, stdout)
assertValidatePasses(t, err)
}
// --- message_ids validation tests (S2) ---
func validMessageIDForTest(s string) string {
return base64.URLEncoding.EncodeToString([]byte(s))
}
func rawMessageIDForTest(s string) string {
return base64.RawURLEncoding.EncodeToString([]byte(s))
}
func TestValidateMessageIDsAcceptsValidIDs(t *testing.T) {
_, err := validateMessageIDs(validMessageIDForTest("biz-001") + "," + validMessageIDForTest("biz-002"))
if err != nil {
t.Fatalf("expected nil error for valid IDs, got: %v", err)
}
}
func TestValidateMessageIDsAcceptsRawBase64URLIDs(t *testing.T) {
_, err := validateMessageIDs(rawMessageIDForTest("biz-raw-001"))
if err != nil {
t.Fatalf("expected nil error for raw base64url ID, got: %v", err)
}
}
func TestValidateMessageIDsRejectsEmpty(t *testing.T) {
_, err := validateMessageIDs("")
assertValidationError(t, err, "--message-ids is required")
_, err = validateMessageIDs(" ")
assertValidationError(t, err, "--message-ids is required")
}
func TestValidateMessageIDsAcceptsMoreThanSingleBackendBatch(t *testing.T) {
ids := make([]string, 21)
for i := range ids {
ids[i] = validMessageIDForTest(string(rune('a' + i)))
}
_, err := validateMessageIDs(strings.Join(ids, ","))
if err != nil {
t.Fatalf("expected nil error for more than one backend batch, got: %v", err)
}
}
func TestValidateMessageIDsRejectsEmptyEntry(t *testing.T) {
_, err := validateMessageIDs(validMessageIDForTest("biz-1") + ",," + validMessageIDForTest("biz-2"))
assertValidationError(t, err, "entry 2 is empty")
}
func TestValidateMessageIDsRejectsLeadingOrTrailingWhitespace(t *testing.T) {
id1 := validMessageIDForTest("biz-1")
id2 := validMessageIDForTest("biz-2")
_, err := validateMessageIDs(id1 + ", " + id2)
assertValidationError(t, err, "must not contain leading or trailing whitespace")
_, err = validateMessageIDs(" " + id1 + "," + id2)
assertValidationError(t, err, "must not contain leading or trailing whitespace")
}
func TestValidateMessageIDsRejectsDuplicateIDs(t *testing.T) {
id := validMessageIDForTest("biz-1")
_, err := validateMessageIDs(id + "," + id)
assertValidationError(t, err, "duplicate message ID is not allowed")
}
func TestValidateMessageIDsRejectsJSONLikeInput(t *testing.T) {
_, err := validateMessageIDs(`["id1","id2"]`)
assertValidationError(t, err, "expected a base64url")
}
func TestValidateMessageIDsRejectsColonJoinedInput(t *testing.T) {
_, err := validateMessageIDs("id1:id2")
assertValidationError(t, err, "expected a base64url")
}
func TestValidateMessageIDsRejectsNumericPrimaryID(t *testing.T) {
_, err := validateMessageIDs("123456789")
assertValidationError(t, err, "numeric primary IDs are not supported")
}
func TestValidateMessageIDsAcceptsExactlyTwenty(t *testing.T) {
ids := make([]string, 20)
for i := range ids {
ids[i] = validMessageIDForTest(string(rune('A' + i)))
}
_, err := validateMessageIDs(strings.Join(ids, ","))
if err != nil {
t.Fatalf("expected nil error for exactly 20 IDs, got: %v", err)
}
}
func TestValidateMessageIDRejectsInvalidBase64(t *testing.T) {
_, err := validateMessageIDs("msg 1")
assertValidationError(t, err, "expected a base64url")
_, err = validateMessageIDs("not-base64!")
assertValidationError(t, err, "expected a base64url")
}

Some files were not shown because too many files have changed in this diff Show More