Compare commits

...

17 Commits

Author SHA1 Message Date
fangshuyu
0efde2f901 perf: lazy-load sheets shortcuts by invocation 2026-06-03 18:00:32 +08:00
zhengzhijie
5ac35fd9fd revert(cli): drop non-interactive proxy-warning silencing
WarnIfProxied's interactivity gate is a generic CLI/agent-UX change
unrelated to the sheets refactor / backward-compat scope of this branch.
Split out to a dedicated PR; restore WarnIfProxied to its single-arg form
here (warn.go, warn_test.go, factory_default.go callers).
2026-06-03 14:58:17 +08:00
xiongyuanwen-byted
1decb4399d test(sheets): drop redundant copyloopvar copy in required-flag parity test
Go 1.22+ scopes the loop var per iteration, so `cmd, business := cmd, business`
in TestBatchOp_RequiredFlagParity is a no-op that trips the repo's copyloopvar
linter (same cleanup as 2132472). Behavior unchanged; 45 sub-tests still pass.
2026-06-03 14:47:15 +08:00
zhengzhijie
d05fbcf041 refactor(sheets): drop unused int64 flag-type plumbing
No sheets flag-def declares an int64 type and RuntimeContext.Int64 had
zero callers, so remove the premature support: the RuntimeContext.Int64
helper, the registerShortcutFlagsWithContext int64 branch, the flagView
Int64 method + mapFlagView impl, and the typedDefault/validateRawTypes
int64 cases. float64 (consumed by --font-size) is kept.
2026-06-03 14:44:07 +08:00
xiongyuanwen-byted
cf47d9b5f9 test(sheets): data-driven required-flag parity contract for batch sub-ops
Adds TestBatchOp_RequiredFlagParity, the systematic standalone-vs-batch parity
check the branch review asked for. Data-driven over batchOpDispatch + flag-defs,
it asserts that for every batchable shortcut a +batch-update sub-op which
satisfies the sheet locator but omits the shortcut's business-required flags
fails in translateBatchOp, never silently defaulting.

This generalizes the hand-picked TestBatchOp_ErrorEquivalence / GuardsBeyondCobra
cases to the full 50-command surface and auto-covers shortcuts added later, so a
future refactor that moves a required check out of the shared *Input builder
(the failure mode behind the csv-put / sheet-move gaps) is caught here. 45
sub-tests run; locator-only commands (+sheet-delete / +sheet-hide / ...) have no
business-required flag to omit and are skipped. A missing-locator error is also
rejected so a bad fixture can't mask a real gap.
2026-06-03 14:40:19 +08:00
zhengzhijie
2132472b87 fix(sheets): silence nilerr/copyloopvar lint in batch type-check additions
- flag_view.go: annotate the fail-open return in validateRawTypes with
  //nolint:nilerr (matches the repo convention for intentional fail-open).
- execute_paths_test.go: drop the redundant tc := tc copy (Go 1.22+ scopes
  the loop var per iteration).
2026-06-03 14:33:19 +08:00
zhengzhijie
27c6333620 fix(sheets): resolve --sheet-name via title + keep bare sheet selectors verbatim
Two review findings on the backward-compat layer:

- lookupSheetIndex matched only sm["sheet_name"], but get_workbook_structure
  surfaces the sub-sheet display name as "title". Every --sheet-name path that
  relies on the lookup (e.g. +sheet-move) failed to resolve. Fall back to
  "title" when "sheet_name" is absent so either field resolves.

- +read / +write / +append fell back to --sheet-id when --range was omitted,
  then routed that bare sheet id through the range normalizer. A sheet id that
  looks A1-ish (letters+digits, e.g. "shtABC123") got mangled into
  "shtABC123!shtABC123:shtABC123". Split the sheet-only path from the
  range-normalization path: read/append pass the selector through verbatim,
  write builds the rect from the selector's A1.

Regression tests added for both paths; sheets suite green.
2026-06-03 14:25:43 +08:00
xiongyuanwen-byted
6bd21ac8c9 fix(cli): structured errors for unknown flags, print-schema, deprecated aliases
From the branch code-review doc (3 findings):

- pure-group UnknownFlags: installUnknownSubcommandGuard whitelists unknown
  flags so a mistyped subcommand still reaches the suggestion path, but a lone
  unknown flag before any subcommand (`sheets --badflag`) was swallowed and the
  group fell through to help + exit 0. unknownSubcommandRunE now recovers the
  swallowed tokens (from os.Args captured at Execute entry) and fails with a
  structured unknown_flag error; a misplaced but known flag (e.g. --format)
  still prints help.

- deprecated-alias notice: a backward-compat alias that fails a cobra-level
  required flag short-circuits before RunE, so the Validate/Execute-wrapped
  deprecation notice was dropped. Added Shortcut.OnInvoke, fired from PreRunE
  (ahead of ValidateRequiredFlags); and the root legacy error fallback now
  routes through the structured envelope when a deprecation is pending so the
  migration hint survives. Non-deprecated errors keep the plain output.

- --print-schema: runShortcut returned the bare error from PrintFlagSchema. It
  is now wrapped as a structured output.ExitError (type print_schema_error) so
  agent introspection can parse the failure.

Tests added for each path; cmd + sheets suites green.
2026-06-03 14:11:02 +08:00
xiongyuanwen-byted
e4309bb5b2 fix(sheets): harden batch type-checking and +workbook-create edge cases
From the branch code-review doc (3 findings):

- +batch-update sub-ops: `operations` is skipped by parse-time schema
  validation and mapFlagView coerces a type-mismatched scalar to its zero
  value, so "index":"abc" or "multiple":"true" silently became 0 / false and
  wrote to the wrong place. translateBatchOp now runs validateRawTypes, which
  checks each sub-op scalar against its flag-defs type and rejects mismatches.

- +workbook-create with empty arrays: buildInitialFillInput returned (nil,nil)
  for empty rows while the caller wrote fill["excel_id"] unconditionally, so
  --values '[]' panicked on a nil map and --headers '[]' produced an illegal
  "A1:1" range. It now also returns nil when no cells survive (maxCols==0
  guard) and Execute/DryRun skip the fill when fill==nil.

- +workbook-create partial failure: after the spreadsheet was created, a
  first-sheet lookup or fill failure returned a bare fmt.Errorf, losing the new
  token. It now returns a structured partial_success error carrying
  spreadsheet_token in the detail so callers can retry or clean up.

Tests added for each path; sheets suite green.
2026-06-03 14:10:59 +08:00
xiongyuanwen-byted
d6a0aadbe4 fix(cli): surface skill in deprecated_command notice
deprecation.Notice carries Skill, but the _notice.deprecated_command payload
dropped it, forcing callers to parse `message` to learn which skill to update.
Emit `skill` when set, alongside the existing `replacement`.
2026-06-03 12:37:25 +08:00
xiongyuanwen-byted
da198cf06a fix(sheets): enforce required-flag contract in batch sub-ops
Batch sub-ops reuse each shortcut's shared *Input builder through mapFlagView,
which seeds flag-defs defaults — so any required check that lives OUTSIDE the
builder (cobra MarkFlagsOneRequired, or a shortcut's own Validate) is silently
bypassed and the default value wins. Two gaps surfaced in PR review:

- +csv-put: with neither --start-cell nor --range set, start-cell's "A1"
  default won and the paste silently anchored at A1. Require an explicit anchor
  (guard on Changed, mirroring the standalone MarkFlagsOneRequired).
- +sheet-move: --index (plus >=0 bounds for index / source-index) was not
  enforced in the batch path; a missing --index silently moved the sheet to the
  front. Mirror SheetMove.Validate.

Also from the same review:
- +batch-update: an explicit --continue-on-error=false now wins over an
  --operations envelope's continue_on_error:true (guard on Changed, not value).
- validateDropdownRanges rejects malformed sheet!range ("!A1", "Sheet1!",
  "Sheet1!bad") at Validate instead of deferring to the server.

Tests added/updated for each path; full sheets suite green.
2026-06-03 12:37:25 +08:00
zhengzhijie
ad7b20935c fix(sheets): correct +dropdown-get sheet-locator doc, finalize skill to 2.0.0
+dropdown-get requires a mandatory sheet selector — its Validate calls
resolveSheetSelector — so drop it from the "no sheet locator" exception
list in SKILL.md. It was wrongly grouped with +dropdown-update/+dropdown-delete,
which take only --ranges. +dropdown-get's own per-shortcut badge (公共四件套)
was already correct. Also finalize the skill version 2.0.0-draft -> 2.0.0.
2026-06-03 12:33:58 +08:00
xiongyuanwen-byted
6e18185eaa chore(sheets): promote lark-sheets skill to 2.0.0
Drop the -draft suffix now that the refactored sheets skill is ready to
ship.
2026-06-03 10:34:49 +08:00
xiongyuanwen-byted
aec1bd4b0c chore: drop hardcoded ppe lane routing from base security headers
The x-tt-env/x-use-ppe headers forced every request onto the
ppe_moa_canvas pre-release lane; they were only meant for exercising the
sheets refactor against the staging backend. Remove them so the CLI
routes to production by default.
2026-06-02 21:25:57 +08:00
xiongyuanwen-byted
fe1b6b7bbb feat(sheets): flag pre-refactor backward aliases via _notice and --help grouping
Nudge users whose lark-sheets skill predates the refactor to migrate off
the pre-refactor aliases (+read, +write, ...), without requiring anyone
to read --help.

- internal/deprecation: process-level pending Notice slot (mirrors
  internal/skillscheck), surfaced in the JSON "_notice" envelope under a
  "deprecated_command" key.
- internal/cmdutil: shared DeprecatedGroupID cobra group + helper so both
  --help rendering and the unknown-subcommand path classify aliases the
  same way.
- shortcuts/register.go: applySheetsCompatGroups splits the aliases into a
  dedicated "update your skill" help group with "(-> +new)" pointers;
  wrapSheetsBackwardDeprecation records the notice from Validate/Execute so
  direct callers that never read --help still get flagged.
- cmd/root.go: extract composePendingNotice (now unit-testable) and split
  availableSubcommandNames into current vs deprecated buckets while still
  ranking unknown-subcommand suggestions across both.
2026-06-02 21:17:56 +08:00
zhengzhijie
4c24c6eb94 fix(sheets): resolve 30 golangci-lint v2.1.6 issues — copyloopvar, nilerr, unused
Removed 25 Go 1.22+ loop variable copies (copyloopvar) from test files where
tc := tc / tt := tt / c := c are no longer needed. Fixed 4 nilerr false
positives in flag_schema_validate.go by making intentional error discards
explicit (schema validation failures skip silently — best-effort guard).
Dropped unused batchOpDispatchKeys helper in batch_op_dispatch.go.
2026-06-02 20:23:05 +08:00
xiongyuanwen-byted
bc1cd72074 feat(sheets): restore pre-refactor shortcuts under backward/ for compatibility
The lark-sheets refactor renamed every shortcut (verb-noun → noun-verb,
e.g. +create-sheet → +sheet-create) and dropped the old commands. External
callers and the tests/cli_e2e/sheets suite still drive the legacy command
names (+create, +read, +write, +create-sheet, ...), which broke.

Re-add the pre-refactor implementations verbatim from main as an isolated
shortcuts/sheets/backward package (package rename only) and register
backward.Shortcuts() alongside sheets.Shortcuts(). Both sets mount under the
`sheets` service; their command names are fully disjoint (38 new vs 42 old,
zero overlap), so old and new commands coexist without collision.
2026-06-02 18:00:23 +08:00
59 changed files with 11501 additions and 213 deletions

61
cmd/notice_test.go Normal file
View File

@@ -0,0 +1,61 @@
// 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,6 +18,7 @@ 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"
@@ -85,7 +86,15 @@ 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)
@@ -149,29 +158,49 @@ func setupNotices() {
skillscheck.Init(build.Version)
// Composed notice provider — emits keys only when each pending is set.
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",
}
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",
}
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.
@@ -269,6 +298,19 @@ 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
}
@@ -335,35 +377,118 @@ 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`) legitimately prints help. But an unknown
// flag placed before any subcommand (`sheets --badflag`) is whitelisted
// away by installUnknownSubcommandGuard, which also leaves args empty —
// without this check it would silently fall through to help + exit 0.
// Recover the swallowed flag tokens and fail structured so agents (and
// the flagDidYouMean contract) still see a real error.
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{
"unknown_flags": unknown,
"command_path": cmd.CommandPath(),
},
},
}
}
return cmd.Help()
}
unknown := args[0]
available := availableSubcommandNames(cmd)
suggestions := suggest.Closest(unknown, available, 6)
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)
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
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
},
Detail: detail,
},
}
}
func availableSubcommandNames(cmd *cobra.Command) []string {
subs := make([]string, 0, len(cmd.Commands()))
// unknownFlagTokens returns the -/-- tokens in rawArgs that cmd does not define.
// installUnknownSubcommandGuard whitelists unknown flags on pure groups so a
// mistyped subcommand still reaches the suggestion path; the side effect is that
// a lone unknown flag (no subcommand) is swallowed, leaving the group to fall
// through to help. This recovers those tokens so the caller can fail structured.
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
var unknown []string
for _, a := range rawArgs {
if a == "--" {
break // everything after -- is positional
}
if len(a) < 2 || a[0] != '-' {
continue
}
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
if name != "" && !flagDefinedInTree(cmd, name) {
unknown = append(unknown, a)
}
}
return unknown
}
// 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) {
for _, c := range cmd.Commands() {
if c.Hidden || !c.IsAvailableCommand() {
continue
@@ -372,10 +497,15 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
if name == "help" || name == "completion" {
continue
}
subs = append(subs, name)
if cmdutil.IsDeprecatedCommand(c) {
deprecated = append(deprecated, name)
} else {
available = append(available, name)
}
}
sort.Strings(subs)
return subs
sort.Strings(available)
sort.Strings(deprecated)
return available, deprecated
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It

View File

@@ -21,6 +21,7 @@ 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"
)
@@ -268,6 +269,54 @@ 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,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -72,6 +73,60 @@ 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())
}
}
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
@@ -164,7 +219,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)
@@ -175,3 +230,61 @@ 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

@@ -102,7 +102,7 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
transport.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
transport.WarnIfProxied(f.IOStreams.ErrOut)
var rt http.RoundTripper = transport.Shared()
rt = &RetryTransport{Base: rt}
@@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHeaders(BaseSecurityHeaders()),
}
transport.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
transport.WarnIfProxied(f.IOStreams.ErrOut)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,

View File

@@ -0,0 +1,18 @@
// 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

@@ -75,8 +75,6 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
h.Set("x-tt-env", "ppe_moa_canvas")
h.Set("x-use-ppe", "1")
if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v)
}

View File

@@ -0,0 +1,57 @@
// 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

@@ -0,0 +1,58 @@
// 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

@@ -73,18 +73,7 @@ func redactProxyURL(raw string) string {
// WarnIfProxied prints a one-time warning to w when a proxy environment variable
// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials
// are redacted. Safe to call multiple times; only the first call prints.
//
// The warning is suppressed entirely when interactive is false — i.e. stdin is
// not a TTY, which is the case for agent / CI / piped invocations. Those callers
// frequently parse the CLI's stdout as JSON and merge streams with `2>&1`; a
// stray stderr warning then corrupts the parsed payload. Suppressing in the
// non-interactive case keeps machine-consumed output clean, while human
// interactive sessions still get the security notice. Passing interactive=false
// does not consume the once guard, so a later interactive call can still warn.
func WarnIfProxied(w io.Writer, interactive bool) {
if !interactive {
return
}
func WarnIfProxied(w io.Writer) {
proxyWarningOnce.Do(func() {
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
// Shared), so its warning and disable instructions take precedence.

View File

@@ -44,7 +44,7 @@ func TestWarnIfProxied_WithProxy(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
out := buf.String()
if out == "" {
@@ -70,30 +70,13 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
}
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
if buf.Len() != 0 {
t.Errorf("expected no output when no proxy is set, got: %s", buf.String())
}
}
func TestWarnIfProxied_SilentWhenNonInteractive(t *testing.T) {
proxyWarningOnce = sync.Once{}
// Non-interactive (interactive=false) mirrors agent / CI / piped invocations
// where stdin is not a TTY. The proxy warning must be suppressed so callers
// that parse stdout as JSON — often merging streams with `2>&1` — are not
// corrupted by a stray stderr line.
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
var buf bytes.Buffer
WarnIfProxied(&buf, false)
if buf.Len() != 0 {
t.Errorf("expected no warning in non-interactive mode, got: %s", buf.String())
}
}
// TestWarnIfProxied_SilentWhenDisabled verifies that LARK_CLI_NO_PROXY suppresses warnings.
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -105,7 +88,7 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
t.Setenv(EnvNoProxy, "1")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
if buf.Len() != 0 {
t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String())
@@ -122,10 +105,10 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) {
t.Setenv("HTTP_PROXY", "http://proxy:1234")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
first := buf.String()
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
second := buf.String()
if first == "" {
@@ -154,7 +137,7 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
t.Setenv(EnvNoProxy, "1")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
out := buf.String()
if !strings.Contains(out, "127.0.0.1:3128") {
@@ -186,7 +169,7 @@ func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
t.Cleanup(func() { proxyPluginStatus = old })
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
out := buf.String()
if !strings.Contains(out, "custom CA") {
@@ -212,7 +195,7 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
t.Cleanup(func() { proxyPluginStatus = old })
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
out := buf.String()
if strings.Contains(out, "s3cret") {
@@ -260,7 +243,7 @@ func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
out := buf.String()
if bytes.Contains([]byte(out), []byte("s3cret")) {

View File

@@ -210,12 +210,6 @@ func (ctx *RuntimeContext) Int(name string) int {
return v
}
// Int64 returns an int64 flag value.
func (ctx *RuntimeContext) Int64(name string) int64 {
v, _ := ctx.Cmd.Flags().GetInt64(name)
return v
}
// Float64 returns a float64 flag value (non-integer numbers).
func (ctx *RuntimeContext) Float64(name string) float64 {
v, _ := ctx.Cmd.Flags().GetFloat64(name)
@@ -771,19 +765,26 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
return runShortcut(cmd, f, &shortcut, botOnly)
},
}
if shortcut.PrintFlagSchema != nil {
// --print-schema is pure local introspection; relax cobra's
// required-flag gate so callers don't need to fill in unrelated
// flags just to ask for a schema. ValidateRequiredFlags runs
// after PreRunE in cobra, so clearing the annotation here is the
// supported way to opt out.
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 want, _ := c.Flags().GetBool("print-schema"); !want {
return nil
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)
})
}
}
c.Flags().VisitAll(func(fl *pflag.Flag) {
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
})
return nil
}
}
@@ -808,6 +809,13 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
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 {
@@ -1073,10 +1081,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 "int64":
var d int64
fmt.Sscanf(fl.Default, "%d", &d)
cmd.Flags().Int64(fl.Name, d, desc)
case "float64":
var d float64
fmt.Sscanf(fl.Default, "%g", &d)

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" | "int64" | "float64" | "string_array" | "string_slice"
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
Default string // default value as string
Desc string // help text
Hidden bool // hidden from --help, still readable at runtime
@@ -58,6 +58,14 @@ 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

View File

@@ -6,7 +6,9 @@ package shortcuts
import (
"context"
"fmt"
"os"
"slices"
"strings"
"github.com/larksuite/cli/shortcuts/okr"
"github.com/spf13/cobra"
@@ -14,6 +16,7 @@ import (
"github.com/larksuite/cli/internal/cmdmeta"
"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"
"github.com/larksuite/cli/shortcuts/apps"
@@ -29,6 +32,7 @@ import (
"github.com/larksuite/cli/shortcuts/markdown"
"github.com/larksuite/cli/shortcuts/minutes"
"github.com/larksuite/cli/shortcuts/sheets"
sheetsbackward "github.com/larksuite/cli/shortcuts/sheets/backward"
"github.com/larksuite/cli/shortcuts/slides"
"github.com/larksuite/cli/shortcuts/task"
"github.com/larksuite/cli/shortcuts/vc"
@@ -53,35 +57,104 @@ func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool {
return slices.Contains(allowed, brand)
}
// allShortcuts aggregates shortcuts from all domain packages.
var allShortcuts []common.Shortcut
// baseShortcuts aggregates every shortcut except sheets. Sheets are kept out of
// the global init path because their registration currently pulls in large
// embedded flag metadata; we only mount them when the current invocation is
// actually targeting sheets (or when completion/help needs the full tree).
var baseShortcuts []common.Shortcut
func init() {
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
allShortcuts = append(allShortcuts, im.Shortcuts()...)
allShortcuts = append(allShortcuts, contact_shortcuts.Shortcuts()...)
allShortcuts = append(allShortcuts, sheets.Shortcuts()...)
allShortcuts = append(allShortcuts, base.Shortcuts()...)
allShortcuts = append(allShortcuts, event.Shortcuts()...)
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
allShortcuts = append(allShortcuts, task.Shortcuts()...)
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
baseShortcuts = append(baseShortcuts, apps.Shortcuts()...)
baseShortcuts = append(baseShortcuts, calendar.Shortcuts()...)
baseShortcuts = append(baseShortcuts, doc.Shortcuts()...)
baseShortcuts = append(baseShortcuts, drive.Shortcuts()...)
baseShortcuts = append(baseShortcuts, im.Shortcuts()...)
baseShortcuts = append(baseShortcuts, contact_shortcuts.Shortcuts()...)
baseShortcuts = append(baseShortcuts, base.Shortcuts()...)
baseShortcuts = append(baseShortcuts, event.Shortcuts()...)
baseShortcuts = append(baseShortcuts, mail.Shortcuts()...)
baseShortcuts = append(baseShortcuts, markdown.Shortcuts()...)
baseShortcuts = append(baseShortcuts, slides.Shortcuts()...)
baseShortcuts = append(baseShortcuts, minutes.Shortcuts()...)
baseShortcuts = append(baseShortcuts, task.Shortcuts()...)
baseShortcuts = append(baseShortcuts, vc.Shortcuts()...)
baseShortcuts = append(baseShortcuts, whiteboard.Shortcuts()...)
baseShortcuts = append(baseShortcuts, wiki.Shortcuts()...)
baseShortcuts = append(baseShortcuts, okr.Shortcuts()...)
}
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
//
//go:noinline
func AllShortcuts() []common.Shortcut {
return append([]common.Shortcut(nil), allShortcuts...)
return append([]common.Shortcut(nil), allShortcuts(true)...)
}
func allShortcuts(includeSheets bool) []common.Shortcut {
out := append([]common.Shortcut(nil), baseShortcuts...)
if includeSheets {
out = append(out, sheets.Shortcuts()...)
// Backward-compatible sheets shortcuts (pre-refactor command names),
// kept under shortcuts/sheets/backward so external callers relying on the
// old `+create`, `+read`, `+write`, ... commands keep working alongside the
// refactored ones. Command names are disjoint from sheets.Shortcuts().
out = append(out, wrapSheetsBackwardDeprecation(sheetsbackward.Shortcuts())...)
}
return out
}
func shouldIncludeSheetsShortcuts(_ *cmdutil.Factory) bool {
args := os.Args[1:]
if len(args) == 0 {
return true
}
rootNames := map[string]struct{}{
"api": {},
"auth": {},
"completion": {},
"config": {},
"doctor": {},
"event": {},
"help": {},
"profile": {},
"schema": {},
"update": {},
}
for _, sc := range baseShortcuts {
rootNames[sc.Service] = struct{}{}
}
rootNames["sheets"] = struct{}{}
rootNames["__complete"] = struct{}{}
rootNames["__completeNoDesc"] = struct{}{}
for i, arg := range args {
if strings.HasPrefix(arg, "-") {
continue
}
if arg == "help" {
for _, next := range args[i+1:] {
if strings.HasPrefix(next, "-") {
continue
}
return next == "sheets"
}
return true
}
if _, ok := rootNames[arg]; !ok {
continue
}
switch arg {
case "sheets", "completion", "__complete", "__completeNoDesc":
return true
default:
return false
}
}
// Unknown argv shape: keep the pre-change conservative behavior and mount
// the full tree rather than accidentally hiding a command.
return true
}
// RegisterShortcuts registers all +shortcut commands on the program.
@@ -100,7 +173,7 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
// Group by service
byService := make(map[string][]common.Shortcut)
for _, s := range allShortcuts {
for _, s := range allShortcuts(shouldIncludeSheetsShortcuts(f)) {
byService[s.Service] = append(byService[s.Service], s)
}
@@ -146,6 +219,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
if service == "mail" {
mail.InstallOnMail(svc)
}
if service == "sheets" {
applySheetsCompatGroups(svc)
}
if !IsShortcutServiceAvailable(service, brand) {
installBrandRestrictionGuard(svc, service, brand)
@@ -189,3 +265,153 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
// --help bypasses RunE, so surface the restriction in Long too.
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
}
// Sheets backward-compatibility help grouping.
//
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that
// users whose lark-sheets skill predates the refactor keep working even after
// upgrading only the binary. In `sheets --help` those aliases would otherwise
// sort alphabetically into the same flat list as the current commands,
// indistinguishable from them. applySheetsCompatGroups splits them into a
// dedicated cobra group whose heading tells the user to update their skill, and
// appends a "(→ +new-command)" pointer to each alias so the migration target is
// obvious. Pure presentation — the aliases stay fully executable.
const (
sheetsCurrentGroupID = "sheets-current"
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
// `sheets --help` grouping and the generic unknown-subcommand path
// (cmd/root.go) classify these aliases the same way.
sheetsDeprecatedGroupID = cmdutil.DeprecatedGroupID
)
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current
// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases
// absent from this map still land in the deprecated group, just without a
// pointer, so a missing entry degrades gracefully rather than misgrouping.
var sheetsAliasReplacement = map[string]string{
// spreadsheet / sheet management
"+create": "+workbook-create",
"+info": "+workbook-info",
"+export": "+workbook-export",
"+create-sheet": "+sheet-create",
"+copy-sheet": "+sheet-copy",
"+delete-sheet": "+sheet-delete",
"+update-sheet": "+sheet-rename / +sheet-move / …",
// cell data
"+read": "+cells-get",
"+write": "+cells-set",
"+append": "+cells-set",
"+find": "+cells-search",
"+replace": "+cells-replace",
// cell style / merge / image
"+set-style": "+cells-set-style",
"+batch-set-style": "+cells-batch-set-style",
"+merge-cells": "+cells-merge",
"+unmerge-cells": "+cells-unmerge",
"+write-image": "+cells-set-image",
// row / column dimensions
"+add-dimension": "+dim-insert",
"+insert-dimension": "+dim-insert",
"+update-dimension": "+rows-resize / +dim-hide / …",
"+move-dimension": "+dim-move",
"+delete-dimension": "+dim-delete",
// filter views (conditions folded into the view flags)
"+create-filter-view": "+filter-view-create",
"+update-filter-view": "+filter-view-update",
"+list-filter-views": "+filter-view-list",
"+get-filter-view": "+filter-view-list",
"+delete-filter-view": "+filter-view-delete",
"+create-filter-view-condition": "+filter-view-update",
"+update-filter-view-condition": "+filter-view-update",
"+list-filter-view-conditions": "+filter-view-list",
"+get-filter-view-condition": "+filter-view-list",
"+delete-filter-view-condition": "+filter-view-update",
// dropdowns
"+set-dropdown": "+dropdown-set",
"+update-dropdown": "+dropdown-update",
"+get-dropdown": "+dropdown-get",
"+delete-dropdown": "+dropdown-delete",
// float images (media-upload folded into create)
"+media-upload": "+float-image-create",
"+create-float-image": "+float-image-create",
"+update-float-image": "+float-image-update",
"+get-float-image": "+float-image-list",
"+list-float-images": "+float-image-list",
"+delete-float-image": "+float-image-delete",
}
func applySheetsCompatGroups(svc *cobra.Command) {
svc.AddGroup(
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
&cobra.Group{
ID: sheetsDeprecatedGroupID,
Title: "Deprecated pre-refactor commands (still work) — update your lark-sheets skill, then: lark-cli update",
},
)
deprecated := make(map[string]struct{})
for _, s := range sheetsbackward.Shortcuts() {
deprecated[s.Command] = struct{}{}
}
for _, c := range svc.Commands() {
name := c.Name()
if _, ok := deprecated[name]; ok {
c.GroupID = sheetsDeprecatedGroupID
if repl := sheetsAliasReplacement[name]; repl != "" {
c.Short = c.Short + " (→ " + repl + ")"
}
continue
}
// Only the refactored shortcuts (all "+"-prefixed) belong in the current
// group. Leave the OpenAPI metaapi subcommands (spreadsheets, ...) and the
// auto-added help/completion ungrouped so cobra files them under
// "Additional Commands".
if len(name) > 0 && name[0] == '+' {
c.GroupID = sheetsCurrentGroupID
}
}
}
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets
// alias so that invoking it records a process-level deprecation notice, which
// cmd/root.go surfaces in the JSON "_notice" envelope. This reaches the users
// the --help grouping cannot: those whose pre-refactor skill calls +read /
// +write directly and never reads --help. Replacement targets come from
// sheetsAliasReplacement — the same single source of truth that drives the
// "(→ +new)" help pointers.
func wrapSheetsBackwardDeprecation(list []common.Shortcut) []common.Shortcut {
for i := range list {
notice := &deprecation.Notice{
Command: list[i].Command,
Replacement: sheetsAliasReplacement[list[i].Command],
Skill: "lark-sheets",
}
// Record the notice as soon as the command's own logic runs, so it is
// surfaced even when Validate rejects the call — an out-of-date skill
// can pass pre-refactor argument shapes (e.g. a range without the new
// sheet-id prefix) and fail validation before Execute — and when
// --dry-run short-circuits before Execute. Both hooks store the same
// pointer, so setting it twice is harmless.
if origValidate := list[i].Validate; origValidate != nil {
list[i].Validate = func(ctx context.Context, runtime *common.RuntimeContext) error {
deprecation.SetPending(notice)
return origValidate(ctx, runtime)
}
}
if origExecute := list[i].Execute; origExecute != nil {
list[i].Execute = func(ctx context.Context, runtime *common.RuntimeContext) error {
deprecation.SetPending(notice)
return origExecute(ctx, runtime)
}
}
// The Validate/Execute wrappers above miss one path: a cobra-level
// required flag (MarkFlagRequired) that is absent fails at
// ValidateRequiredFlags, before RunE — so neither hook runs and the
// notice would be lost on exactly the "stale skill calls the old command
// and mis-supplies flags" case it exists for. OnInvoke runs from PreRunE,
// ahead of ValidateRequiredFlags, so the notice still surfaces there.
list[i].OnInvoke = func() { deprecation.SetPending(notice) }
}
return list
}

View File

@@ -5,6 +5,7 @@ package shortcuts
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -16,7 +17,9 @@ import (
"github.com/larksuite/cli/internal/cmdmeta"
"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/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -46,8 +49,15 @@ func newRegisterTestProgramWithTipsHelp() *cobra.Command {
return program
}
func withRegisterTestArgs(t *testing.T, args ...string) {
t.Helper()
orig := os.Args
os.Args = append([]string{"lark-cli"}, args...)
t.Cleanup(func() { os.Args = orig })
}
func TestAllShortcutsScopesNotNil(t *testing.T) {
for _, s := range allShortcuts {
for _, s := range AllShortcuts() {
hasScopes := s.Scopes != nil || s.UserScopes != nil || s.BotScopes != nil
if !hasScopes {
t.Errorf("shortcut %s/%s: Scopes is nil (must be explicitly set, use []string{} if no scopes needed)", s.Service, s.Command)
@@ -107,6 +117,30 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
}
}
func TestRegisterShortcuts_SkipsSheetsWhenInvocationTargetsOtherService(t *testing.T) {
program := &cobra.Command{Use: "root"}
withRegisterTestArgs(t, "auth", "status")
RegisterShortcuts(program, newRegisterTestFactory(t))
if _, _, err := program.Find([]string{"sheets", "+workbook-info"}); err == nil {
t.Fatal("unexpected sheets shortcut mounted for non-sheets invocation")
}
}
func TestRegisterShortcuts_IncludesSheetsWhenInvocationTargetsSheets(t *testing.T) {
program := &cobra.Command{Use: "root"}
withRegisterTestArgs(t, "sheets", "+workbook-info")
RegisterShortcuts(program, newRegisterTestFactory(t))
cmd, _, err := program.Find([]string{"sheets", "+workbook-info"})
if err != nil {
t.Fatalf("find sheets shortcut: %v", err)
}
if cmd == nil || cmd.Name() != "+workbook-info" {
t.Fatalf("sheets shortcut not mounted: %#v", cmd)
}
}
// Service-level cobra commands created by RegisterShortcuts must carry
// the cmdmeta.Domain annotation so plugin Selectors (platform.ByDomain)
// and Rule.Allow path-globs can resolve a command's business domain.
@@ -471,3 +505,152 @@ func TestGenerateShortcutsJSON(t *testing.T) {
}
t.Logf("wrote %d bytes to %s", len(data), output)
}
// applySheetsCompatGroups must split the sheets service into a current group
// (refactored "+"-shortcuts) and a deprecated group (backward-compat aliases),
// append a "(→ +new)" migration pointer to each alias, and leave non-"+"
// subcommands (OpenAPI metaapi, help/completion) ungrouped so cobra files them
// under "Additional Commands".
func TestApplySheetsCompatGroups(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
newCmd := &cobra.Command{Use: "+cells-get", Short: "Read ranges"}
aliasCmd := &cobra.Command{Use: "+read", Short: "Read spreadsheet cell values"}
metaCmd := &cobra.Command{Use: "spreadsheets", Short: "spreadsheets operations"}
svc.AddCommand(newCmd, aliasCmd, metaCmd)
applySheetsCompatGroups(svc)
if !svc.ContainsGroup(sheetsCurrentGroupID) {
t.Errorf("current group %q not registered", sheetsCurrentGroupID)
}
if !svc.ContainsGroup(sheetsDeprecatedGroupID) {
t.Errorf("deprecated group %q not registered", sheetsDeprecatedGroupID)
}
if newCmd.GroupID != sheetsCurrentGroupID {
t.Errorf("+cells-get GroupID = %q, want %q", newCmd.GroupID, sheetsCurrentGroupID)
}
if aliasCmd.GroupID != sheetsDeprecatedGroupID {
t.Errorf("+read GroupID = %q, want %q", aliasCmd.GroupID, sheetsDeprecatedGroupID)
}
if !strings.Contains(aliasCmd.Short, "(→ +cells-get)") {
t.Errorf("+read Short missing migration pointer, got %q", aliasCmd.Short)
}
if metaCmd.GroupID != "" {
t.Errorf("metaapi spreadsheets should stay ungrouped, got GroupID %q", metaCmd.GroupID)
}
}
// End-to-end: the rendered `sheets --help` must surface the deprecated-group
// heading (telling users to update their skill) plus the per-alias migration
// pointers, while keeping the refactored shortcuts under Available Commands.
func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
sheetsCmd, _, err := program.Find([]string{"sheets"})
if err != nil {
t.Fatalf("find sheets command: %v", err)
}
var out bytes.Buffer
sheetsCmd.SetOut(&out)
if err := sheetsCmd.Help(); err != nil {
t.Fatalf("sheets help failed: %v", err)
}
got := out.String()
for _, want := range []string{
"Available Commands:",
"Deprecated pre-refactor commands",
"update your lark-sheets skill",
"+read",
"(→ +cells-get)",
"+write",
"(→ +cells-set)",
} {
if !strings.Contains(got, want) {
t.Fatalf("sheets help missing %q:\n%s", want, got)
}
}
}
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that
// invoking it records a process-level deprecation notice (reusing
// sheetsAliasReplacement for the migration target) while still calling the
// original Execute. cmd/root.go reads that notice into the JSON "_notice".
func TestWrapSheetsBackwardDeprecation(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
called := false
in := []common.Shortcut{{
Service: "sheets",
Command: "+read",
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
called = true
return nil
},
}}
out := wrapSheetsBackwardDeprecation(in)
if len(out) != 1 {
t.Fatalf("wrapped list len = %d, want 1", len(out))
}
if deprecation.GetPending() != nil {
t.Fatal("notice set before wrapped Execute ran")
}
if err := out[0].Execute(context.Background(), nil); err != nil {
t.Fatalf("wrapped Execute returned error: %v", err)
}
if !called {
t.Fatal("original Execute was not invoked by the wrapper")
}
dep := deprecation.GetPending()
if dep == nil {
t.Fatal("expected a pending deprecation notice after Execute")
}
if dep.Command != "+read" {
t.Errorf("notice Command = %q, want +read", dep.Command)
}
if dep.Replacement != "+cells-get" {
t.Errorf("notice Replacement = %q, want +cells-get (from sheetsAliasReplacement)", dep.Replacement)
}
if dep.Skill != "lark-sheets" {
t.Errorf("notice Skill = %q, want lark-sheets", dep.Skill)
}
}
// The wrapper must also decorate Validate, so an out-of-date skill whose
// pre-refactor argument shape fails validation (before Execute) still gets the
// deprecation notice in its error envelope.
func TestWrapSheetsBackwardDeprecationValidateHook(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
validated := false
in := []common.Shortcut{{
Service: "sheets",
Command: "+write",
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
validated = true
return nil
},
}}
out := wrapSheetsBackwardDeprecation(in)
if out[0].Validate == nil {
t.Fatal("Validate hook was dropped by the wrapper")
}
if err := out[0].Validate(context.Background(), nil); err != nil {
t.Fatalf("wrapped Validate returned error: %v", err)
}
if !validated {
t.Fatal("original Validate was not invoked")
}
dep := deprecation.GetPending()
if dep == nil || dep.Command != "+write" || dep.Replacement != "+cells-set" {
t.Fatalf("Validate hook did not record expected notice: %#v", dep)
}
}

View File

@@ -0,0 +1,239 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var (
singleCellRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*$`)
cellSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+[1-9][0-9]*$`)
cellToColRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+$`)
colSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+:[A-Za-z]+$`)
rowSpanRangePattern = regexp.MustCompile(`^[1-9][0-9]*:[1-9][0-9]*$`)
cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`)
)
var sheetRangeSeparatorReplacer = strings.NewReplacer(`\`, "!", `\!`, "!", "", "!")
// getFirstSheetID queries the spreadsheet and returns the first sheet's ID.
func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
if err != nil {
return "", err
}
sheets, _ := data["sheets"].([]interface{})
if len(sheets) > 0 {
sheet, _ := sheets[0].(map[string]interface{})
if id, ok := sheet["sheet_id"].(string); ok && id != "" {
return id, nil
}
}
return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet")
}
// extractSpreadsheetToken extracts spreadsheet token from URL.
func extractSpreadsheetToken(input string) string {
input = strings.TrimSpace(input)
prefixes := []string{"/sheets/", "/spreadsheets/"}
for _, prefix := range prefixes {
if idx := strings.Index(input, prefix); idx >= 0 {
token := input[idx+len(prefix):]
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
token = token[:idx2]
}
return token
}
}
return input
}
func normalizeSheetRange(sheetID, input string) string {
input = normalizeSheetRangeSeparators(input)
if input == "" || strings.Contains(input, "!") || sheetID == "" {
return input
}
if looksLikeRelativeRange(input) {
return sheetID + "!" + input
}
return input
}
func normalizePointRange(sheetID, input string) string {
input = normalizeSheetRange(sheetID, input)
if input == "" {
return input
}
rangeSheetID, subRange, ok := splitSheetRange(input)
if !ok || !singleCellRangePattern.MatchString(subRange) {
return input
}
return rangeSheetID + "!" + subRange + ":" + subRange
}
func normalizeWriteRange(sheetID, input string, values interface{}) string {
rows, cols := matrixDimensions(values)
input = normalizeSheetRangeSeparators(input)
if input == "" {
return buildRectRange(sheetID, "A1", rows, cols)
}
input = normalizeSheetRange(sheetID, input)
rangeSheetID, subRange, ok := splitSheetRange(input)
if !ok {
return buildRectRange(input, "A1", rows, cols)
}
if singleCellRangePattern.MatchString(subRange) {
return buildRectRange(rangeSheetID, subRange, rows, cols)
}
return input
}
func validateSheetRangeInput(sheetID, input string) error {
input = normalizeSheetRangeSeparators(input)
if input == "" || strings.Contains(input, "!") || sheetID != "" {
return nil
}
if looksLikeRelativeRange(input) {
return common.FlagErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
}
return nil
}
// validateSingleCellRange rejects multi-cell spans (e.g. "A1:B2") that are
// invalid for single-cell operations like write-image. Empty and single-cell
// values pass through.
func validateSingleCellRange(input string) error {
input = normalizeSheetRangeSeparators(input)
if input == "" {
return nil
}
// Extract the sub-range after the sheet ID prefix, if present.
subRange := input
if _, sr, ok := splitSheetRange(input); ok {
subRange = sr
}
if cellSpanRangePattern.MatchString(subRange) {
parts := strings.SplitN(subRange, ":", 2)
if strings.EqualFold(parts[0], parts[1]) {
return nil
}
return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
}
return nil
}
func looksLikeRelativeRange(input string) bool {
input = normalizeSheetRangeSeparators(input)
if input == "" {
return false
}
return singleCellRangePattern.MatchString(input) ||
cellSpanRangePattern.MatchString(input) ||
cellToColRangePattern.MatchString(input) ||
colSpanRangePattern.MatchString(input) ||
rowSpanRangePattern.MatchString(input)
}
func splitSheetRange(input string) (sheetID, subRange string, ok bool) {
parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
return parts[0], parts[1], true
}
func normalizeSheetRangeSeparators(input string) string {
input = strings.TrimSpace(input)
if input == "" {
return input
}
return sheetRangeSeparatorReplacer.Replace(input)
}
func buildRectRange(sheetID, anchor string, rows, cols int) string {
if sheetID == "" {
return ""
}
if rows < 1 {
rows = 1
}
if cols < 1 {
cols = 1
}
endCell, err := offsetCell(anchor, rows-1, cols-1)
if err != nil {
return sheetID
}
return sheetID + "!" + anchor + ":" + endCell
}
func matrixDimensions(values interface{}) (rows, cols int) {
rowList, ok := values.([]interface{})
if !ok || len(rowList) == 0 {
return 1, 1
}
rows = len(rowList)
for _, row := range rowList {
if cells, ok := row.([]interface{}); ok && len(cells) > cols {
cols = len(cells)
}
}
if cols == 0 {
cols = 1
}
return rows, cols
}
func offsetCell(cell string, rowOffset, colOffset int) (string, error) {
matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell))
if len(matches) != 3 {
return "", fmt.Errorf("invalid cell reference: %s", cell)
}
colIndex := columnNameToIndex(matches[1])
if colIndex < 1 {
return "", fmt.Errorf("invalid column: %s", matches[1])
}
rowIndex, err := strconv.Atoi(matches[2])
if err != nil {
return "", err
}
return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil
}
func columnNameToIndex(name string) int {
name = strings.ToUpper(strings.TrimSpace(name))
if name == "" {
return 0
}
index := 0
for _, r := range name {
if r < 'A' || r > 'Z' {
return 0
}
index = index*26 + int(r-'A'+1)
}
return index
}
func columnIndexToName(index int) string {
if index < 1 {
return ""
}
var out []byte
for index > 0 {
index--
out = append([]byte{byte('A' + index%26)}, out...)
index /= 26
}
return string(out)
}

View File

@@ -0,0 +1,436 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func parseValues2DJSON(raw string) ([][]interface{}, error) {
var rows [][]interface{}
if err := json.Unmarshal([]byte(raw), &rows); err != nil {
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
}
if rows == nil {
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
}
return rows, nil
}
var SheetRead = common.Shortcut{
Service: "sheets",
Command: "+read",
Description: "Read spreadsheet cell values",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "read range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
{Name: "sheet-id", Desc: "sheet ID"},
{Name: "value-render-option", Desc: "render option: ToString|FormattedValue|Formula|UnformattedValue"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
if r := runtime.Str("range"); r != "" {
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
readRange := runtime.Str("range")
if readRange == "" {
// Sheet-only selector: pass the bare sheet id through verbatim.
// Routing it via the range normalizer mangles ids that look
// A1-ish (e.g. "shtABC123" -> "shtABC123!shtABC123:shtABC123").
readRange = runtime.Str("sheet-id")
} else {
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
}
return common.NewDryRunAPI().
GET("/open-apis/sheets/v2/spreadsheets/:token/values/:range").
Set("token", token).Set("range", readRange)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
readRange := runtime.Str("range")
if readRange == "" {
// Sheet-only selector: keep the resolved sheet id verbatim (see DryRun).
readRange = runtime.Str("sheet-id")
if readRange == "" {
var err error
readRange, err = getFirstSheetID(runtime, token)
if err != nil {
return err
}
}
} else {
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
}
params := map[string]interface{}{}
renderOption := runtime.Str("value-render-option")
if renderOption != "" {
params["valueRenderOption"] = renderOption
}
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetWrite = common.Shortcut{
Service: "sheets",
Command: "+write",
Description: "Write to spreadsheet cells (overwrite mode)",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "write range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
{Name: "sheet-id", Desc: "sheet ID"},
{Name: "values", Desc: "2D array JSON", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if _, err := parseValues2DJSON(runtime.Str("values")); err != nil {
return err
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
writeRange := runtime.Str("range")
values, _ := parseValues2DJSON(runtime.Str("values"))
if writeRange == "" {
// Sheet-only selector: build the write rect from the selector's
// A1 instead of treating the bare sheet id as a cell anchor.
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), "", values)
} else {
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
}
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/values").
Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": writeRange, "values": values}}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
values, err := parseValues2DJSON(runtime.Str("values"))
if err != nil {
return err
}
writeRange := runtime.Str("range")
if writeRange == "" {
// Sheet-only selector: build the write rect from the selector's
// A1 (see DryRun). Resolve the first sheet when none was given.
sel := runtime.Str("sheet-id")
if sel == "" {
var err error
sel, err = getFirstSheetID(runtime, token)
if err != nil {
return err
}
}
writeRange = normalizeWriteRange(sel, "", values)
} else {
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
}
data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"valueRange": map[string]interface{}{
"range": writeRange,
"values": values,
},
})
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetAppend = common.Shortcut{
Service: "sheets",
Command: "+append",
Description: "Append rows to a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "append range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
{Name: "sheet-id", Desc: "sheet ID"},
{Name: "values", Desc: "2D array JSON", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if _, err := parseValues2DJSON(runtime.Str("values")); err != nil {
return err
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
appendRange := runtime.Str("range")
if appendRange == "" {
// Sheet-only selector: pass the bare sheet id through verbatim
// (see SheetRead.DryRun for the normalizer-mangling rationale).
appendRange = runtime.Str("sheet-id")
} else {
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
}
values, _ := parseValues2DJSON(runtime.Str("values"))
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/values_append").
Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": appendRange, "values": values}}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
values, err := parseValues2DJSON(runtime.Str("values"))
if err != nil {
return err
}
appendRange := runtime.Str("range")
if appendRange == "" {
// Sheet-only selector: keep the resolved sheet id verbatim (see DryRun).
appendRange = runtime.Str("sheet-id")
if appendRange == "" {
var err error
appendRange, err = getFirstSheetID(runtime, token)
if err != nil {
return err
}
}
} else {
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
}
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"valueRange": map[string]interface{}{
"range": appendRange,
"values": values,
},
})
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetFind = common.Shortcut{
Service: "sheets",
Command: "+find",
Description: "Find cells in a spreadsheet",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "find", Desc: "search text", Required: true},
{Name: "range", Desc: "search range (<sheetId>!A1:D10, or A1:D10 / C2 with --sheet-id)"},
{Name: "ignore-case", Type: "bool", Desc: "case-insensitive search"},
{Name: "match-entire-cell", Type: "bool", Desc: "match entire cell"},
{Name: "search-by-regex", Type: "bool", Desc: "regex search"},
{Name: "include-formulas", Type: "bool", Desc: "search formulas"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
if r := runtime.Str("range"); r != "" {
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
sheetID := runtime.Str("sheet-id")
findCondition := map[string]interface{}{
"range": sheetID,
"match_case": !runtime.Bool("ignore-case"),
"match_entire_cell": runtime.Bool("match-entire-cell"),
"search_by_regex": runtime.Bool("search-by-regex"),
"include_formulas": runtime.Bool("include-formulas"),
}
if runtime.Str("range") != "" {
findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range"))
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/find").
Body(map[string]interface{}{
"find": runtime.Str("find"),
"find_condition": findCondition,
}).
Set("token", token).Set("sheet_id", sheetID).Set("find", runtime.Str("find"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
sheetID := runtime.Str("sheet-id")
findText := runtime.Str("find")
findCondition := map[string]interface{}{
"range": sheetID,
"match_case": !runtime.Bool("ignore-case"),
"match_entire_cell": runtime.Bool("match-entire-cell"),
"search_by_regex": runtime.Bool("search-by-regex"),
"include_formulas": runtime.Bool("include-formulas"),
}
if runtime.Str("range") != "" {
findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range"))
}
reqData := map[string]interface{}{
"find_condition": findCondition,
"find": findText,
}
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetReplace = common.Shortcut{
Service: "sheets",
Command: "+replace",
Description: "Find and replace cell values in a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "find", Desc: "search text or regex pattern", Required: true},
{Name: "replacement", Desc: "replacement text", Required: true},
{Name: "range", Desc: "search range (<sheetId>!A1:D10, or A1:D10 with --sheet-id)"},
{Name: "match-case", Type: "bool", Desc: "case-sensitive search"},
{Name: "match-entire-cell", Type: "bool", Desc: "match entire cell content"},
{Name: "search-by-regex", Type: "bool", Desc: "use regex search"},
{Name: "include-formulas", Type: "bool", Desc: "search in formulas"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
if r := runtime.Str("range"); r != "" {
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
sheetID := runtime.Str("sheet-id")
findCondition := map[string]interface{}{
"range": sheetID,
"match_case": runtime.Bool("match-case"),
"match_entire_cell": runtime.Bool("match-entire-cell"),
"search_by_regex": runtime.Bool("search-by-regex"),
"include_formulas": runtime.Bool("include-formulas"),
}
if runtime.Str("range") != "" {
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/replace").
Body(map[string]interface{}{
"find_condition": findCondition,
"find": runtime.Str("find"),
"replacement": runtime.Str("replacement"),
}).
Set("token", token).Set("sheet_id", sheetID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
sheetID := runtime.Str("sheet-id")
findCondition := map[string]interface{}{
"range": sheetID,
"match_case": runtime.Bool("match-case"),
"match_entire_cell": runtime.Bool("match-entire-cell"),
"search_by_regex": runtime.Bool("search-by-regex"),
"include_formulas": runtime.Bool("include-formulas"),
}
if runtime.Str("range") != "" {
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
}
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace",
validate.EncodePathSegment(token),
validate.EncodePathSegment(sheetID),
),
nil,
map[string]interface{}{
"find_condition": findCondition,
"find": runtime.Str("find"),
"replacement": runtime.Str("replacement"),
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetWriteImage = common.Shortcut{
Service: "sheets",
Command: "+write-image",
Description: "Write an image into a spreadsheet cell",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID"},
{Name: "range", Desc: "target cell (e.g. A1 or <sheetId>!A1). Start and end cell must be the same", Required: true},
{Name: "image", Desc: "local image file path (supported formats: PNG, JPEG, JPG, GIF, BMP, JFIF, EXIF, TIFF, BPG, HEIC)", Required: true},
{Name: "name", Desc: "image file name with extension (defaults to the basename of --image)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
if err := validateSingleCellRange(runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
imageName := runtime.Str("name")
if imageName == "" {
imageName = filepath.Base(runtime.Str("image"))
}
return common.NewDryRunAPI().
Desc("JSON upload with inline image bytes").
POST("/open-apis/sheets/v2/spreadsheets/:token/values_image").
Body(map[string]interface{}{
"range": pointRange,
"image": fmt.Sprintf("<binary: %s>", runtime.Str("image")),
"name": imageName,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
imagePath := runtime.Str("image")
fio := runtime.FileIO()
stat, err := validateSheetWriteImageFile(fio, imagePath)
if err != nil {
return err
}
imageFile, err := fio.Open(imagePath)
if err != nil {
return wrapSheetWriteImageOpenError(err)
}
defer imageFile.Close()
imageBytes, err := io.ReadAll(imageFile)
if err != nil {
return output.ErrValidation("cannot read image file: %s", err)
}
imageName := runtime.Str("name")
if imageName == "" {
imageName = filepath.Base(imagePath)
}
fmt.Fprintf(runtime.IO().ErrOut, "Writing image: %s (%d bytes) → %s\n", imageName, stat.Size(), pointRange)
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"range": pointRange,
"image": imageBytes,
"name": imageName,
})
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
func validateSheetWriteImageFile(fio fileio.FileIO, imagePath string) (fileio.FileInfo, error) {
if fio == nil {
return nil, output.ErrValidation("no file I/O provider registered")
}
stat, err := fio.Stat(imagePath)
if err != nil {
return nil, wrapSheetWriteImageStatError(err, imagePath)
}
if stat.IsDir() || !stat.Mode().IsRegular() {
return nil, output.ErrValidation("image must be a regular file: %s", imagePath)
}
const maxImageSize int64 = 20 * 1024 * 1024
if stat.Size() > maxImageSize {
return nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
}
return stat, nil
}
func wrapSheetWriteImageStatError(err error, imagePath string) error {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe image path: %s", err)
}
if os.IsNotExist(err) {
return output.ErrValidation("image file not found: %s", imagePath)
}
return output.ErrValidation("cannot stat image file: %s", err)
}
func wrapSheetWriteImageOpenError(err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe image path: %s", err)
}
return output.ErrValidation("cannot read image file: %s", err)
}

View File

@@ -0,0 +1,350 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func validateBatchStyleData(raw string) error {
var data interface{}
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
}
arr, ok := data.([]interface{})
if !ok || len(arr) == 0 {
return common.FlagErrorf("--data must be a non-empty JSON array")
}
for i, item := range arr {
entry, ok := item.(map[string]interface{})
if !ok {
return common.FlagErrorf("--data[%d] must be an object with ranges and style", i)
}
rangesRaw, ok := entry["ranges"]
if !ok {
return common.FlagErrorf("--data[%d].ranges is required", i)
}
ranges, ok := rangesRaw.([]interface{})
if !ok || len(ranges) == 0 {
return common.FlagErrorf("--data[%d].ranges must be a non-empty array of strings", i)
}
for j, r := range ranges {
s, ok := r.(string)
if !ok || s == "" {
return common.FlagErrorf("--data[%d].ranges[%d] must be a non-empty string", i, j)
}
if _, _, ok := splitSheetRange(s); !ok {
return common.FlagErrorf("--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s)
}
}
styleRaw, ok := entry["style"]
if !ok {
return common.FlagErrorf("--data[%d].style is required", i)
}
if _, ok := styleRaw.(map[string]interface{}); !ok {
return common.FlagErrorf("--data[%d].style must be a JSON object", i)
}
}
return nil
}
var SheetSetStyle = common.Shortcut{
Service: "sheets",
Command: "+set-style",
Description: "Set cell style for a range",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
{Name: "style", Desc: "style JSON object (e.g. {\"font\":{\"bold\":true},\"backColor\":\"#ff0000\"})", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)
}
if _, ok := style.(map[string]interface{}); !ok {
return common.FlagErrorf("--style must be a JSON object, got %T", style)
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
_ = json.Unmarshal([]byte(runtime.Str("style")), &style) // Validate already parses and validates this JSON.
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/style").
Body(map[string]interface{}{
"appendStyle": map[string]interface{}{
"range": r,
"style": style,
},
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)
}
data, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"appendStyle": map[string]interface{}{
"range": r,
"style": style,
},
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetBatchSetStyle = common.Shortcut{
Service: "sheets",
Command: "+batch-set-style",
Description: "Batch set cell styles for multiple ranges",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
return validateBatchStyleData(runtime.Str("data"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
var data interface{}
_ = json.Unmarshal([]byte(runtime.Str("data")), &data) // Validate already parses and validates this JSON via validateBatchStyleData().
normalizeBatchStyleRanges(data)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
Body(map[string]interface{}{
"data": data,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
var data interface{}
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
}
normalizeBatchStyleRanges(data)
result, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"data": data,
},
)
if err != nil {
return err
}
runtime.Out(result, nil)
return nil
},
}
func normalizeBatchStyleRanges(data interface{}) {
items, ok := data.([]interface{})
if !ok {
return
}
for _, item := range items {
entry, ok := item.(map[string]interface{})
if !ok {
continue
}
ranges, ok := entry["ranges"].([]interface{})
if !ok {
continue
}
for i, r := range ranges {
if s, ok := r.(string); ok {
ranges[i] = normalizePointRange("", s)
}
}
}
}
var SheetMergeCells = common.Shortcut{
Service: "sheets",
Command: "+merge-cells",
Description: "Merge cells in a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
{Name: "merge-type", Desc: "merge method", Required: true, Enum: []string{"MERGE_ALL", "MERGE_ROWS", "MERGE_COLUMNS"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/merge_cells").
Body(map[string]interface{}{
"range": r,
"mergeType": runtime.Str("merge-type"),
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"range": r,
"mergeType": runtime.Str("merge-type"),
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetUnmergeCells = common.Shortcut{
Service: "sheets",
Command: "+unmerge-cells",
Description: "Unmerge (split) cells in a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/unmerge_cells").
Body(map[string]interface{}{
"range": r,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"range": r,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,333 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func dataValidationBasePath(token string) string {
return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dataValidation",
validate.EncodePathSegment(token))
}
func dataValidationSheetPath(token, sheetID string) string {
return fmt.Sprintf("%s/%s", dataValidationBasePath(token), validate.EncodePathSegment(sheetID))
}
func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
func parseJSONStringArray(flagName, value string) ([]interface{}, error) {
var typed []string
if err := json.Unmarshal([]byte(value), &typed); err != nil {
return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err)
}
if typed == nil {
return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName)
}
arr := make([]interface{}, len(typed))
for i, s := range typed {
arr[i] = s
}
return arr, nil
}
func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return nil, err
}
if len(ranges) == 0 {
return nil, common.FlagErrorf("--ranges must not be empty")
}
for i, r := range ranges {
s, _ := r.(string)
if _, _, ok := splitSheetRange(s); !ok {
return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s)
}
}
return ranges, nil
}
func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
condValues, err := parseJSONStringArray("condition-values", runtime.Str("condition-values"))
if err != nil {
return nil, err
}
if len(condValues) == 0 {
return nil, common.FlagErrorf("--condition-values must not be empty")
}
dv := map[string]interface{}{
"conditionValues": condValues,
}
opts := map[string]interface{}{}
if runtime.Cmd.Flags().Changed("multiple") {
opts["multipleValues"] = runtime.Bool("multiple")
}
if runtime.Cmd.Flags().Changed("highlight") {
opts["highlightValidData"] = runtime.Bool("highlight")
}
if runtime.Str("colors") != "" {
colors, err := parseJSONStringArray("colors", runtime.Str("colors"))
if err != nil {
return nil, err
}
if len(colors) != len(condValues) {
return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues))
}
opts["colors"] = colors
}
if len(opts) > 0 {
dv["options"] = opts
}
return dv, nil
}
// SheetSetDropdown sets dropdown list validation on a range.
var SheetSetDropdown = common.Shortcut{
Service: "sheets",
Command: "+set-dropdown",
Description: "Set dropdown list on a cell range",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]'), max 500, each <=100 chars, no commas`, Required: true},
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
{Name: "colors", Desc: `RGB hex color array (e.g. '["#1FB6C1","#F006C2"]'), must match condition-values length`},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
}
_, err := buildDropdownBody(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
dv, _ := buildDropdownBody(runtime)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
Body(map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
"dataValidation": dv,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
dv, err := buildDropdownBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil,
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
"dataValidation": dv,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetUpdateDropdown updates dropdown list settings for given ranges.
var SheetUpdateDropdown = common.Shortcut{
Service: "sheets",
Command: "+update-dropdown",
Description: "Update dropdown list settings",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A1:A100"]')`, Required: true},
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]')`, Required: true},
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
{Name: "colors", Desc: `RGB hex color array, must match condition-values length`},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, err := validateRangesFlag(runtime); err != nil {
return err
}
_, err := buildDropdownBody(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
dv, _ := buildDropdownBody(runtime)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/dataValidation/:sheet_id").
Body(map[string]interface{}{
"ranges": ranges,
"dataValidationType": "list",
"dataValidation": dv,
}).
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return err
}
dv, err := buildDropdownBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
map[string]interface{}{
"ranges": ranges,
"dataValidationType": "list",
"dataValidation": dv,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetGetDropdown queries dropdown list settings for a range.
var SheetGetDropdown = common.Shortcut{
Service: "sheets",
Command: "+get-dropdown",
Description: "Get dropdown list settings for a range",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v2/spreadsheets/:token/dataValidation?range=:range&dataValidationType=list").
Set("token", token).Set("range", runtime.Str("range"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
data, err := runtime.CallAPI("GET", dataValidationBasePath(token),
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
}, nil,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetDeleteDropdown deletes dropdown list settings from given ranges.
var SheetDeleteDropdown = common.Shortcut{
Service: "sheets",
Command: "+delete-dropdown",
Description: "Delete dropdown list from cell ranges",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A2:A100"]'), max 100 ranges`, Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
_, err := validateRangesFlag(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
dvRanges := make([]interface{}, 0, len(ranges))
for _, r := range ranges {
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
}
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
Body(map[string]interface{}{
"dataValidationRanges": dvRanges,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return err
}
dvRanges := make([]interface{}, 0, len(ranges))
for _, r := range ranges {
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
}
data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil,
map[string]interface{}{
"dataValidationRanges": dvRanges,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,489 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func filterViewBasePath(token, sheetID string) string {
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/filter_views",
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
}
func filterViewItemPath(token, sheetID, filterViewID string) string {
return fmt.Sprintf("%s/%s", filterViewBasePath(token, sheetID), validate.EncodePathSegment(filterViewID))
}
func filterViewConditionBasePath(token, sheetID, filterViewID string) string {
return fmt.Sprintf("%s/conditions", filterViewItemPath(token, sheetID, filterViewID))
}
func filterViewConditionItemPath(token, sheetID, filterViewID, conditionID string) string {
return fmt.Sprintf("%s/%s", filterViewConditionBasePath(token, sheetID, filterViewID), validate.EncodePathSegment(conditionID))
}
func validateFilterViewToken(runtime *common.RuntimeContext) (string, error) {
return validateSheetManageToken(runtime)
}
func hasNonEmptyStringFlag(runtime *common.RuntimeContext, name string) bool {
return runtime.Cmd.Flags().Changed(name) && strings.TrimSpace(runtime.Str(name)) != ""
}
var SheetCreateFilterView = common.Shortcut{
Service: "sheets",
Command: "+create-filter-view",
Description: "Create a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "range", Desc: "filter range (e.g. sheetId!A1:H14)", Required: true},
{Name: "filter-view-name", Desc: "display name (max 100 chars)"},
{Name: "filter-view-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.FlagErrorf("--range must not be empty")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{"range": runtime.Str("range")}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
if s := runtime.Str("filter-view-id"); s != "" {
body["filter_view_id"] = s
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{"range": runtime.Str("range")}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
if s := runtime.Str("filter-view-id"); s != "" {
body["filter_view_id"] = s
}
data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetUpdateFilterView = common.Shortcut{
Service: "sheets",
Command: "+update-filter-view",
Description: "Update a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "range", Desc: "new filter range"},
{Name: "filter-view-name", Desc: "new display name (max 100 chars)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
if !hasNonEmptyStringFlag(runtime, "range") &&
!hasNonEmptyStringFlag(runtime, "filter-view-name") {
return common.FlagErrorf("specify at least one of --range or --filter-view-name")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{}
if s := runtime.Str("range"); s != "" {
body["range"] = s
}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
return common.NewDryRunAPI().
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{}
if s := runtime.Str("range"); s != "" {
body["range"] = s
}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetListFilterViews = common.Shortcut{
Service: "sheets",
Command: "+list-filter-views",
Description: "List all filter views in a sheet",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/query").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetGetFilterView = common.Shortcut{
Service: "sheets",
Command: "+get-filter-view",
Description: "Get a filter view by ID",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetDeleteFilterView = common.Shortcut{
Service: "sheets",
Command: "+delete-filter-view",
Description: "Delete a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetCreateFilterViewCondition = common.Shortcut{
Service: "sheets",
Command: "+create-filter-view-condition",
Description: "Create a filter condition on a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color", Required: true},
{Name: "compare-type", Desc: "comparison operator (e.g. less, beginsWith, between)"},
{Name: "expected", Desc: "filter values JSON array (e.g. [\"6\"])", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
return validateExpectedFlag(runtime.Str("expected"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, true)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, true)
data, err := runtime.CallAPI("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetUpdateFilterViewCondition = common.Shortcut{
Service: "sheets",
Command: "+update-filter-view-condition",
Description: "Update a filter condition on a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color"},
{Name: "compare-type", Desc: "comparison operator"},
{Name: "expected", Desc: "filter values JSON array"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
if !hasNonEmptyStringFlag(runtime, "filter-type") &&
!hasNonEmptyStringFlag(runtime, "compare-type") &&
!hasNonEmptyStringFlag(runtime, "expected") {
return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected")
}
if s := runtime.Str("expected"); s != "" {
return validateExpectedFlag(s)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, false)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, false)
data, err := runtime.CallAPI("PUT",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetListFilterViewConditions = common.Shortcut{
Service: "sheets",
Command: "+list-filter-view-conditions",
Description: "List all filter conditions of a filter view",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/query").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET",
filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"))+"/query",
nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetGetFilterViewCondition = common.Shortcut{
Service: "sheets",
Command: "+get-filter-view-condition",
Description: "Get a filter condition by column",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetDeleteFilterViewCondition = common.Shortcut{
Service: "sheets",
Command: "+delete-filter-view-condition",
Description: "Delete a filter condition from a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("DELETE",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
func validateExpectedFlag(s string) error {
if s == "" {
return nil
}
var arr []interface{}
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return output.ErrValidation("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
}
return nil
}
func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) map[string]interface{} {
body := map[string]interface{}{}
if includeConditionID {
body["condition_id"] = runtime.Str("condition-id")
}
if s := runtime.Str("filter-type"); s != "" {
body["filter_type"] = s
}
if s := runtime.Str("compare-type"); s != "" {
body["compare_type"] = s
}
if s := runtime.Str("expected"); s != "" {
var arr []interface{}
_ = json.Unmarshal([]byte(s), &arr)
body["expected"] = arr
}
return body
}

View File

@@ -0,0 +1,464 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"fmt"
"path/filepath"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const sheetImageParentType = "sheet_image"
var SheetMediaUpload = common.Shortcut{
Service: "sheets",
Command: "+media-upload",
Description: "Upload a local image for use as a floating image and return the file_token",
Risk: "write",
Scopes: []string{"docs:document.media:upload"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "file", Desc: "local image path (files > 20MB use multipart upload automatically)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSheetMediaUploadParent(runtime); err != nil {
return err
}
_, _, err := validateSheetMediaUploadFile(runtime, runtime.Str("file"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
parentNode, err := resolveSheetMediaUploadParent(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
filePath := runtime.Str("file")
fileName := filepath.Base(filePath)
dry := common.NewDryRunAPI()
if sheetMediaShouldUseMultipart(runtime.FileIO(), filePath) {
dry.Desc("chunked media upload (files > 20MB)").
POST("/open-apis/drive/v1/medias/upload_prepare").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": sheetImageParentType,
"parent_node": parentNode,
"size": "<file_size>",
}).
POST("/open-apis/drive/v1/medias/upload_part").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/medias/upload_finish").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
return dry.Set("spreadsheet_token", parentNode)
}
return dry.Desc("multipart/form-data upload").
POST("/open-apis/drive/v1/medias/upload_all").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": sheetImageParentType,
"parent_node": parentNode,
"size": "<file_size>",
"file": "@" + filePath,
}).
Set("spreadsheet_token", parentNode)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
parentNode, err := resolveSheetMediaUploadParent(runtime)
if err != nil {
return err
}
filePath := runtime.Str("file")
safePath, stat, err := validateSheetMediaUploadFile(runtime, filePath)
if err != nil {
return err
}
fileName := filepath.Base(safePath)
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> spreadsheet %s\n",
fileName, common.FormatSize(stat.Size()), common.MaskToken(parentNode))
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
}
fileToken, err := uploadSheetMediaFile(runtime, safePath, fileName, stat.Size(), parentNode)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"file_token": fileToken,
"file_name": fileName,
"size": stat.Size(),
"spreadsheet_token": parentNode,
}, nil)
return nil
},
}
func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath string) (string, fileio.FileInfo, error) {
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return "", nil, common.WrapInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return "", nil, output.ErrValidation("file must be a regular file: %s", filePath)
}
return filePath, stat, nil
}
func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if u := runtime.Str("url"); u != "" {
if parsed := extractSpreadsheetToken(u); parsed != "" {
token = parsed
}
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) {
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
pn := parentNode
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: sheetImageParentType,
ParentNode: &pn,
})
}
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: sheetImageParentType,
ParentNode: parentNode,
})
}
func sheetMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
info, err := fio.Stat(filePath)
if err != nil {
return false
}
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
}
func floatImageBasePath(token, sheetID string) string {
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/float_images",
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
}
func floatImageItemPath(token, sheetID, floatImageID string) string {
return fmt.Sprintf("%s/%s", floatImageBasePath(token, sheetID), validate.EncodePathSegment(floatImageID))
}
func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if u := runtime.Str("url"); u != "" {
if parsed := extractSpreadsheetToken(u); parsed != u {
token = parsed
}
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
func validateFloatImageRange(sheetID, rangeVal string) error {
if rangeVal == "" {
return nil
}
if err := validateSingleCellRange(rangeVal); err != nil {
return err
}
if prefix, _, ok := splitSheetRange(rangeVal); ok && sheetID != "" && prefix != sheetID {
return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID)
}
return nil
}
func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
hasField := runtime.Str("range") != "" ||
runtime.Cmd.Flags().Changed("width") ||
runtime.Cmd.Flags().Changed("height") ||
runtime.Cmd.Flags().Changed("offset-x") ||
runtime.Cmd.Flags().Changed("offset-y")
if !hasField {
return common.FlagErrorf("specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
}
return nil
}
func validateFloatImageDims(runtime *common.RuntimeContext) error {
if runtime.Cmd.Flags().Changed("width") {
if v := runtime.Int("width"); v < 20 {
return common.FlagErrorf("--width must be >= 20 pixels, got %d", v)
}
}
if runtime.Cmd.Flags().Changed("height") {
if v := runtime.Int("height"); v < 20 {
return common.FlagErrorf("--height must be >= 20 pixels, got %d", v)
}
}
if runtime.Cmd.Flags().Changed("offset-x") {
if v := runtime.Int("offset-x"); v < 0 {
return common.FlagErrorf("--offset-x must be >= 0, got %d", v)
}
}
if runtime.Cmd.Flags().Changed("offset-y") {
if v := runtime.Int("offset-y"); v < 0 {
return common.FlagErrorf("--offset-y must be >= 0, got %d", v)
}
}
return nil
}
func buildFloatImageBody(runtime *common.RuntimeContext, includeToken bool) map[string]interface{} {
body := map[string]interface{}{}
if includeToken {
if s := runtime.Str("float-image-token"); s != "" {
body["float_image_token"] = s
}
}
if s := runtime.Str("range"); s != "" {
body["range"] = s
}
if runtime.Cmd.Flags().Changed("width") {
body["width"] = runtime.Int("width")
}
if runtime.Cmd.Flags().Changed("height") {
body["height"] = runtime.Int("height")
}
if runtime.Cmd.Flags().Changed("offset-x") {
body["offset_x"] = runtime.Int("offset-x")
}
if runtime.Cmd.Flags().Changed("offset-y") {
body["offset_y"] = runtime.Int("offset-y")
}
return body
}
var SheetCreateFloatImage = common.Shortcut{
Service: "sheets",
Command: "+create-float-image",
Description: "Create a floating image on a sheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "float-image-token", Desc: "image file token (from upload API)", Required: true},
{Name: "range", Desc: "anchor cell, must be a single cell (e.g. sheetId!A1:A1)", Required: true},
{Name: "width", Type: "int", Desc: "width in pixels (>=20)"},
{Name: "height", Type: "int", Desc: "height in pixels (>=20)"},
{Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"},
{Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"},
{Name: "float-image-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFloatImageToken(runtime); err != nil {
return err
}
if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return validateFloatImageDims(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, true)
if s := runtime.Str("float-image-id"); s != "" {
body["float_image_id"] = s
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, true)
if s := runtime.Str("float-image-id"); s != "" {
body["float_image_id"] = s
}
data, err := runtime.CallAPI("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetUpdateFloatImage = common.Shortcut{
Service: "sheets",
Command: "+update-float-image",
Description: "Update a floating image",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "float-image-id", Desc: "float image ID", Required: true},
{Name: "range", Desc: "new anchor cell, must be a single cell (e.g. sheetId!B2:B2)"},
{Name: "width", Type: "int", Desc: "width in pixels (>=20)"},
{Name: "height", Type: "int", Desc: "height in pixels (>=20)"},
{Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"},
{Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFloatImageToken(runtime); err != nil {
return err
}
if err := validateFloatImageUpdatePayload(runtime); err != nil {
return err
}
if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return validateFloatImageDims(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, false)
return common.NewDryRunAPI().
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, false)
data, err := runtime.CallAPI("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetGetFloatImage = common.Shortcut{
Service: "sheets",
Command: "+get-float-image",
Description: "Get a floating image by ID",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "float-image-id", Desc: "float image ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFloatImageToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetListFloatImages = common.Shortcut{
Service: "sheets",
Command: "+list-float-images",
Description: "List all floating images in a sheet",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFloatImageToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/query").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetDeleteFloatImage = common.Shortcut{
Service: "sheets",
Command: "+delete-float-image",
Description: "Delete a floating image",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "float-image-id", Desc: "float image ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFloatImageToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,369 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetAddDimension = common.Shortcut{
Service: "sheets",
Command: "+add-dimension",
Description: "Add rows or columns at the end of a sheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "length", Type: "int", Desc: "number of rows/columns to add (1-5000)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
length := runtime.Int("length")
if length < 1 || length > 5000 {
return common.FlagErrorf("--length must be between 1 and 5000, got %d", length)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
Body(map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"length": runtime.Int("length"),
},
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"length": runtime.Int("length"),
},
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetInsertDimension = common.Shortcut{
Service: "sheets",
Command: "+insert-dimension",
Description: "Insert rows or columns at a specified position",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "start position (0-indexed)", Required: true},
{Name: "end-index", Type: "int", Desc: "end position (0-indexed, exclusive)", Required: true},
{Name: "inherit-style", Desc: "style inheritance: BEFORE or AFTER", Enum: []string{"BEFORE", "AFTER"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if runtime.Int("start-index") < 0 {
return common.FlagErrorf("--start-index must be >= 0")
}
if runtime.Int("end-index") <= runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be greater than --start-index")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
body := map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
}
if s := runtime.Str("inherit-style"); s != "" {
body["inheritStyle"] = s
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/insert_dimension_range").
Body(body).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
body := map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
}
if s := runtime.Str("inherit-style"); s != "" {
body["inheritStyle"] = s
}
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)),
nil, body,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetUpdateDimension = common.Shortcut{
Service: "sheets",
Command: "+update-dimension",
Description: "Update row or column properties (visibility, size)",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
{Name: "visible", Type: "bool", Desc: "true to show, false to hide"},
{Name: "fixed-size", Type: "int", Desc: "row height or column width in pixels"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if runtime.Int("start-index") < 1 {
return common.FlagErrorf("--start-index must be >= 1")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
}
if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") {
return common.FlagErrorf("specify at least one of --visible or --fixed-size")
}
if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 {
return common.FlagErrorf("--fixed-size must be >= 1")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
props := map[string]interface{}{}
if runtime.Cmd.Flags().Changed("visible") {
props["visible"] = runtime.Bool("visible")
}
if runtime.Cmd.Flags().Changed("fixed-size") {
props["fixedSize"] = runtime.Int("fixed-size")
}
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
Body(map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
"dimensionProperties": props,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
props := map[string]interface{}{}
if runtime.Cmd.Flags().Changed("visible") {
props["visible"] = runtime.Bool("visible")
}
if runtime.Cmd.Flags().Changed("fixed-size") {
props["fixedSize"] = runtime.Int("fixed-size")
}
data, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
"dimensionProperties": props,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetMoveDimension = common.Shortcut{
Service: "sheets",
Command: "+move-dimension",
Description: "Move rows or columns to a new position",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "source start position (0-indexed)", Required: true},
{Name: "end-index", Type: "int", Desc: "source end position (0-indexed, inclusive)", Required: true},
{Name: "destination-index", Type: "int", Desc: "target position to move to (0-indexed)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if runtime.Int("start-index") < 0 {
return common.FlagErrorf("--start-index must be >= 0")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
}
if runtime.Int("destination-index") < 0 {
return common.FlagErrorf("--destination-index must be >= 0")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/move_dimension").
Body(map[string]interface{}{
"source": map[string]interface{}{
"major_dimension": runtime.Str("dimension"),
"start_index": runtime.Int("start-index"),
"end_index": runtime.Int("end-index"),
},
"destination_index": runtime.Int("destination-index"),
}).
Set("token", token).
Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
validate.EncodePathSegment(token),
validate.EncodePathSegment(runtime.Str("sheet-id")),
),
nil,
map[string]interface{}{
"source": map[string]interface{}{
"major_dimension": runtime.Str("dimension"),
"start_index": runtime.Int("start-index"),
"end_index": runtime.Int("end-index"),
},
"destination_index": runtime.Int("destination-index"),
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetDeleteDimension = common.Shortcut{
Service: "sheets",
Command: "+delete-dimension",
Description: "Delete rows or columns",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if runtime.Int("start-index") < 1 {
return common.FlagErrorf("--start-index must be >= 1")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
Body(map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("DELETE",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,781 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── MergeCells ───────────────────────────────────────────────────────────────
func TestSheetMergeCellsValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL",
}, nil)
err := SheetMergeCells.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetMergeCellsValidateRelativeRangeWithoutSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL",
}, nil)
err := SheetMergeCells.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--sheet-id") {
t.Fatalf("expected sheet-id error, got: %v", err)
}
}
func TestSheetMergeCellsValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ROWS",
}, nil)
if err := SheetMergeCells.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetMergeCellsDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1", "merge-type": "MERGE_ALL",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetMergeCells.DryRun(context.Background(), rt))
if !strings.Contains(got, `merge_cells`) {
t.Fatalf("DryRun URL missing merge_cells: %s", got)
}
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
t.Fatalf("DryRun range not normalized: %s", got)
}
if !strings.Contains(got, `"mergeType":"MERGE_ALL"`) {
t.Fatalf("DryRun missing mergeType: %s", got)
}
}
func TestSheetMergeCellsExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}},
})
err := mountAndRunSheets(t, SheetMergeCells, []string{
"+merge-cells", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "spreadsheetToken") {
t.Fatalf("stdout missing spreadsheetToken: %s", stdout.String())
}
}
func TestSheetMergeCellsExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetMergeCells, []string{
"+merge-cells", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── UnmergeCells ─────────────────────────────────────────────────────────────
func TestSheetUnmergeCellsValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "",
}, nil)
err := SheetUnmergeCells.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetUnmergeCellsValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
}, nil)
if err := SheetUnmergeCells.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetUnmergeCellsDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "sheet1!A1:B2", "sheet-id": "",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetUnmergeCells.DryRun(context.Background(), rt))
if !strings.Contains(got, `unmerge_cells`) {
t.Fatalf("DryRun URL missing unmerge_cells: %s", got)
}
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
t.Fatalf("DryRun missing range: %s", got)
}
}
func TestSheetUnmergeCellsExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}},
})
err := mountAndRunSheets(t, SheetUnmergeCells, []string{
"+unmerge-cells", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetUnmergeCellsExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetUnmergeCells, []string{
"+unmerge-cells", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── Replace ──────────────────────────────────────────────────────────────────
func TestSheetReplaceValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "find": "a", "replacement": "b", "range": "",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
err := SheetReplace.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetReplaceValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "find": "hello", "replacement": "world", "range": "",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
if err := SheetReplace.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetReplaceValidateMismatchedRangeSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b",
"range": "sheet2!A1:B2",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
err := SheetReplace.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "does not match") {
t.Fatalf("expected mismatch error, got: %v", err)
}
}
func TestSheetReplaceValidateMatchingRangeSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b",
"range": "sheet1!A1:B2",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
if err := SheetReplace.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetReplaceDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "old", "replacement": "new", "range": "A1:C5",
}, map[string]bool{"match-case": true, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt))
if !strings.Contains(got, `replace`) {
t.Fatalf("DryRun URL missing replace: %s", got)
}
if !strings.Contains(got, `"find":"old"`) {
t.Fatalf("DryRun missing find: %s", got)
}
if !strings.Contains(got, `"replacement":"new"`) {
t.Fatalf("DryRun missing replacement: %s", got)
}
if !strings.Contains(got, `"match_case":true`) {
t.Fatalf("DryRun missing match_case: %s", got)
}
}
func TestSheetReplaceDryRunNoRange(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "a", "replacement": "b", "range": "",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt))
// When no range specified, range defaults to sheet-id
if !strings.Contains(got, `"range":"sheet1"`) {
t.Fatalf("DryRun range should default to sheet-id: %s", got)
}
}
func TestSheetReplaceExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"replace_result": map[string]interface{}{
"matched_cells": []interface{}{"A1"}, "rows_count": float64(1),
},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetReplace, []string{
"+replace", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--find", "hello", "--replacement", "world", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "matched_cells") {
t.Fatalf("stdout missing matched_cells: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["find"] != "hello" || body["replacement"] != "world" {
t.Fatalf("unexpected body: %#v", body)
}
}
func TestSheetReplaceExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetReplace, []string{
"+replace", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--find", "a", "--replacement", "b", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── SetStyle ─────────────────────────────────────────────────────────────────
func TestSheetSetStyleValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `{"font":{"bold":true}}`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetSetStyleValidateInvalidJSON(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `{invalid}`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--style must be valid JSON") {
t.Fatalf("expected JSON error, got: %v", err)
}
}
func TestSheetSetStyleValidateRejectsArray(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `[{"bold":true}]`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "JSON object") {
t.Fatalf("expected object error, got: %v", err)
}
}
func TestSheetSetStyleValidateRejectsString(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `"bold"`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "JSON object") {
t.Fatalf("expected object error, got: %v", err)
}
}
func TestSheetSetStyleValidateRejectsNull(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `null`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "JSON object") {
t.Fatalf("expected object error, got: %v", err)
}
}
func TestSheetSetStyleValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `{"font":{"bold":true},"backColor":"#ff0000"}`,
}, nil)
if err := SheetSetStyle.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetSetStyleDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1",
"style": `{"font":{"bold":true}}`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `/style`) {
t.Fatalf("DryRun URL missing /style: %s", got)
}
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
t.Fatalf("DryRun range not normalized: %s", got)
}
if !strings.Contains(got, `"bold":true`) {
t.Fatalf("DryRun missing style: %s", got)
}
}
func TestSheetSetStyleExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"updates": map[string]interface{}{"updatedCells": float64(4), "updatedRange": "sheet1!A1:B2"},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetSetStyle, []string{
"+set-style", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "updatedCells") {
t.Fatalf("stdout missing updatedCells: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
appendStyle, _ := body["appendStyle"].(map[string]interface{})
if appendStyle["range"] != "sheet1!A1:B2" {
t.Fatalf("unexpected range: %v", appendStyle["range"])
}
}
func TestSheetSetStyleDryRunExpandsSingleCell(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "A1", "sheet-id": "sheet1",
"style": `{"font":{"bold":true}}`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt))
if !strings.Contains(got, `"range":"sheet1!A1:A1"`) {
t.Fatalf("DryRun should expand single cell to A1:A1: %s", got)
}
}
func TestSheetSetStyleExecuteExpandsSingleCell(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"updates": map[string]interface{}{"updatedCells": float64(1), "updatedRange": "sheet1!A1:A1"},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetSetStyle, []string{
"+set-style", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--range", "A1",
"--style", `{"font":{"bold":true}}`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
appendStyle, _ := body["appendStyle"].(map[string]interface{})
if appendStyle["range"] != "sheet1!A1:A1" {
t.Fatalf("single cell should be expanded to sheet1!A1:A1, got: %v", appendStyle["range"])
}
}
func TestSheetSetStyleExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetSetStyle, []string{
"+set-style", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── BatchSetStyle ────────────────────────────────────────────────────────────
func TestSheetBatchSetStyleValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "",
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetBatchSetStyleValidateInvalidJSON(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "data": `not-json`,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--data must be valid JSON") {
t.Fatalf("expected JSON error, got: %v", err)
}
}
func TestSheetBatchSetStyleValidateNotArray(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "data": `{"not":"array"}`,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") {
t.Fatalf("expected array error, got: %v", err)
}
}
func TestSheetBatchSetStyleValidateEmptyArray(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "data": `[]`,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") {
t.Fatalf("expected empty array error, got: %v", err)
}
}
func TestSheetBatchSetStyleValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`,
}, nil)
if err := SheetBatchSetStyle.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetBatchSetStyleValidateRejectsMalformedEntries(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data string
wantSubst string
}{
{
name: "entry must be object",
data: `["bad"]`,
wantSubst: "must be an object with ranges and style",
},
{
name: "ranges required",
data: `[{"style":{}}]`,
wantSubst: ".ranges is required",
},
{
name: "ranges must be array",
data: `[{"ranges":"sheet1!A1","style":{}}]`,
wantSubst: ".ranges must be a non-empty array of strings",
},
{
name: "ranges must not be empty",
data: `[{"ranges":[],"style":{}}]`,
wantSubst: ".ranges must be a non-empty array of strings",
},
{
name: "range must include sheet prefix",
data: `[{"ranges":["A1"],"style":{}}]`,
wantSubst: "must include a sheetId! prefix",
},
{
name: "style required",
data: `[{"ranges":["sheet1!A1:B2"]}]`,
wantSubst: ".style is required",
},
{
name: "style must be object",
data: `[{"ranges":["sheet1!A1:B2"],"style":"bad"}]`,
wantSubst: ".style must be a JSON object",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "data": tt.data,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
}
})
}
}
func TestSheetBatchSetStyleDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test",
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"backColor":"#ff0000"}}]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt))
if !strings.Contains(got, `styles_batch_update`) {
t.Fatalf("DryRun URL missing styles_batch_update: %s", got)
}
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
}
func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"totalUpdatedCells": float64(4), "revision": float64(90),
}},
})
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
"--data", `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "totalUpdatedCells") {
t.Fatalf("stdout missing totalUpdatedCells: %s", stdout.String())
}
}
func TestSheetBatchSetStyleDryRunExpandsSingleCells(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test",
"data": `[{"ranges":["sheet1!A2","sheet1!B2"],"style":{"font":{"bold":true}}}]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt))
if !strings.Contains(got, `"sheet1!A2:A2"`) || !strings.Contains(got, `"sheet1!B2:B2"`) {
t.Fatalf("DryRun should expand single cells to A2:A2 and B2:B2: %s", got)
}
}
func TestSheetBatchSetStyleExecuteNormalizesMixedRanges(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"totalUpdatedCells": float64(5),
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
"--data", `[{"ranges":["sheet1!C1:D2","sheet1!E3"],"style":{"font":{"italic":true}}}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
data, _ := body["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("expected 1 data entry, got %d", len(data))
}
entry, _ := data[0].(map[string]interface{})
ranges, _ := entry["ranges"].([]interface{})
if len(ranges) != 2 || ranges[0] != "sheet1!C1:D2" || ranges[1] != "sheet1!E3:E3" {
t.Fatalf("ranges should preserve span and expand single cell, got: %v", ranges)
}
}
func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
"--data", `[{"ranges":["sheet1!A1:B2"],"style":{}}]`, "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
func TestNormalizeBatchStyleRanges(t *testing.T) {
t.Parallel()
t.Run("single cell with sheet prefix is expanded in place", func(t *testing.T) {
t.Parallel()
data := []interface{}{
map[string]interface{}{
"ranges": []interface{}{"sheet1!A1", "sheet1!B2"},
"style": map[string]interface{}{"font": map[string]interface{}{"bold": true}},
},
}
normalizeBatchStyleRanges(data)
got := data[0].(map[string]interface{})["ranges"].([]interface{})
if got[0] != "sheet1!A1:A1" || got[1] != "sheet1!B2:B2" {
t.Fatalf("want [sheet1!A1:A1 sheet1!B2:B2], got %v", got)
}
})
t.Run("multi-cell span passes through unchanged", func(t *testing.T) {
t.Parallel()
data := []interface{}{
map[string]interface{}{
"ranges": []interface{}{"sheet1!A1:B2"},
},
}
normalizeBatchStyleRanges(data)
got := data[0].(map[string]interface{})["ranges"].([]interface{})
if got[0] != "sheet1!A1:B2" {
t.Fatalf("multi-cell span should be unchanged, got %v", got[0])
}
})
t.Run("bare single cell without sheet prefix passes through", func(t *testing.T) {
t.Parallel()
// Without a sheetId! prefix there's no sheet context; entry is left
// alone and the backend will reject it. Documented in the helper.
data := []interface{}{
map[string]interface{}{
"ranges": []interface{}{"A1"},
},
}
normalizeBatchStyleRanges(data)
got := data[0].(map[string]interface{})["ranges"].([]interface{})
if got[0] != "A1" {
t.Fatalf("bare single cell should pass through, got %v", got[0])
}
})
t.Run("non-string entries are preserved", func(t *testing.T) {
t.Parallel()
data := []interface{}{
map[string]interface{}{
"ranges": []interface{}{"sheet1!A1", 42, nil, "sheet1!B2"},
},
}
normalizeBatchStyleRanges(data)
got := data[0].(map[string]interface{})["ranges"].([]interface{})
if got[0] != "sheet1!A1:A1" {
t.Fatalf("first entry should be expanded, got %v", got[0])
}
if got[1] != 42 {
t.Fatalf("int entry should be preserved, got %v", got[1])
}
if got[2] != nil {
t.Fatalf("nil entry should be preserved, got %v", got[2])
}
if got[3] != "sheet1!B2:B2" {
t.Fatalf("last entry should be expanded, got %v", got[3])
}
})
t.Run("missing or non-array ranges key is skipped", func(t *testing.T) {
t.Parallel()
data := []interface{}{
map[string]interface{}{
"style": map[string]interface{}{"font": map[string]interface{}{"bold": true}},
},
map[string]interface{}{
"ranges": "not-an-array",
},
"not-a-map",
}
normalizeBatchStyleRanges(data)
if data[1].(map[string]interface{})["ranges"] != "not-an-array" {
t.Fatal("non-array ranges should be left alone")
}
})
t.Run("top-level non-array inputs do not panic", func(t *testing.T) {
t.Parallel()
// Any of these would panic if the helper didn't guard its type assertions.
normalizeBatchStyleRanges(nil)
normalizeBatchStyleRanges(map[string]interface{}{"foo": "bar"})
normalizeBatchStyleRanges("string")
normalizeBatchStyleRanges(42)
})
}

View File

@@ -0,0 +1,391 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestSheetCreateBotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_new_sheet",
"url": "https://example.feishu.cn/sheets/shtcn_new_sheet",
},
},
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/shtcn_new_sheet/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
}
reg.Register(permStub)
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目排期",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new spreadsheet." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestSheetCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_new_sheet",
"url": "https://example.feishu.cn/sheets/shtcn_new_sheet",
},
},
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目排期",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestSheetCreateFallbackURLWhenBackendOmitsIt(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_new_sheet",
// "url" deliberately omitted to exercise the fallback.
},
},
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目排期",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want {
t.Fatalf("url = %#v, want %q (brand-standard fallback)", got, want)
}
}
func TestSheetCreateDryRunIncludesFolderToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{
"title": "项目排期",
"folder-token": "fldcn123",
"headers": "",
"data": "",
},
nil, nil)
got := mustMarshalSheetsDryRun(t, SheetCreate.DryRun(context.Background(), rt))
if !strings.Contains(got, `"folder_token":"fldcn123"`) {
t.Fatalf("DryRun should include folder_token, got: %s", got)
}
}
func TestSheetCreatePreservesBackendURL(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_new_sheet",
"url": "https://tenant.larkoffice.com/sheets/shtcn_new_sheet",
},
},
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目排期",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want {
t.Fatalf("url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
}
}
func TestSheetCreateFallbackURLWhenBackendURLIsWhitespace(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_new_sheet",
"url": " ", // whitespace-only must trigger fallback, not pass through.
},
},
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目排期",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want {
t.Fatalf("url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want)
}
}
func TestSheetCreateTrimsPaddedBackendURL(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_new_sheet",
"url": " https://tenant.larkoffice.com/sheets/shtcn_new_sheet ",
},
},
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目排期",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want {
t.Fatalf("url = %#v, want trimmed backend URL %q (whitespace must not leak into output)", got, want)
}
}
func sheetCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
t.Helper()
replacer := strings.NewReplacer("/", "-", " ", "-")
suffix := replacer.Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "test-sheet-create-" + suffix,
AppSecret: "secret-sheet-create-" + suffix,
Brand: core.BrandFeishu,
UserOpenId: userOpenID,
}
}
func runSheetCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "sheets"}
SheetCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func decodeSheetCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in output envelope: %#v", envelope)
}
return data
}
func TestSheetCreateBotAutoGrantSkippedNoUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_skipped",
"url": "https://example.feishu.cn/sheets/shtcn_skipped",
},
},
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "No User Sheet",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
}
}
func TestSheetCreateBotAutoGrantFailed(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_grant_fail",
"url": "https://example.feishu.cn/sheets/shtcn_grant_fail",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/shtcn_grant_fail/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Grant Fail Sheet",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
}
}

View File

@@ -0,0 +1,979 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"encoding/json"
"strconv"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// newDimTestRuntime creates a RuntimeContext with string, int, and bool flags.
func newDimTestRuntime(t *testing.T, strFlags map[string]string, intFlags map[string]int, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
for name := range strFlags {
cmd.Flags().String(name, "", "")
}
for name := range intFlags {
cmd.Flags().Int(name, 0, "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, value := range strFlags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, value := range intFlags {
if err := cmd.Flags().Set(name, strconv.Itoa(value)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, value := range boolFlags {
if err := cmd.Flags().Set(name, strconv.FormatBool(value)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
func marshalDryRun(t *testing.T, v interface{}) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
return string(b)
}
// ── AddDimension ─────────────────────────────────────────────────────────────
func TestSheetAddDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"length": 10}, nil)
err := SheetAddDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetAddDimensionValidateLengthOutOfRange(t *testing.T) {
t.Parallel()
for _, length := range []int{0, -1, 5001} {
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"length": length}, nil)
err := SheetAddDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--length") {
t.Fatalf("length=%d: expected length error, got: %v", length, err)
}
}
}
func TestSheetAddDimensionValidateSuccess(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"length": 100}, nil)
if err := SheetAddDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetAddDimensionValidateWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"length": 5}, nil)
if err := SheetAddDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDimensionShortcutsValidateRejectURLAndTokenTogether(t *testing.T) {
t.Parallel()
tests := []struct {
name string
shortcut common.Shortcut
strFlags map[string]string
intFlags map[string]int
boolFlags map[string]bool
}{
{
name: "add",
shortcut: SheetAddDimension,
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"},
intFlags: map[string]int{"length": 1},
},
{
name: "insert",
shortcut: SheetInsertDimension,
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS", "inherit-style": ""},
intFlags: map[string]int{"start-index": 0, "end-index": 1},
},
{
name: "update",
shortcut: SheetUpdateDimension,
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"},
intFlags: map[string]int{"start-index": 1, "end-index": 1},
boolFlags: map[string]bool{"visible": true},
},
{
name: "move",
shortcut: SheetMoveDimension,
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"},
intFlags: map[string]int{"start-index": 0, "end-index": 0, "destination-index": 1},
},
{
name: "delete",
shortcut: SheetDeleteDimension,
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"},
intFlags: map[string]int{"start-index": 1, "end-index": 1},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, tt.boolFlags)
err := tt.shortcut.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutual exclusivity error, got: %v", err)
}
})
}
}
func TestSheetAddDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"length": 8}, nil)
got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `dimension_range`) {
t.Fatalf("DryRun URL missing dimension_range: %s", got)
}
if !strings.Contains(got, `"sheetId":"sheet1"`) {
t.Fatalf("DryRun missing sheetId: %s", got)
}
if !strings.Contains(got, `"majorDimension":"ROWS"`) {
t.Fatalf("DryRun missing majorDimension: %s", got)
}
if !strings.Contains(got, `"length":8`) {
t.Fatalf("DryRun missing length: %s", got)
}
}
func TestSheetAddDimensionDryRunWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"length": 3}, nil)
got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, "shtFromURL") {
t.Fatalf("DryRun should extract token from URL: %s", got)
}
}
func TestSheetAddDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Body: map[string]interface{}{
"code": 0, "msg": "Success",
"data": map[string]interface{}{"addCount": float64(8), "majorDimension": "ROWS"},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetAddDimension, []string{
"+add-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--length", "8",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"addCount"`) {
t.Fatalf("stdout missing addCount: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
dim, _ := body["dimension"].(map[string]interface{})
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
t.Fatalf("unexpected request body: %#v", body)
}
}
func TestSheetAddDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Status: 400,
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
})
err := mountAndRunSheets(t, SheetAddDimension, []string{
"+add-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--length", "8",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}
// ── InsertDimension ──────────────────────────────────────────────────────────
func TestSheetInsertDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
map[string]int{"start-index": 0, "end-index": 3}, nil)
err := SheetInsertDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetInsertDimensionValidateNegativeStartIndex(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
map[string]int{"start-index": -1, "end-index": 3}, nil)
err := SheetInsertDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--start-index") {
t.Fatalf("expected start-index error, got: %v", err)
}
}
func TestSheetInsertDimensionValidateEndNotGreaterThanStart(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
map[string]int{"start-index": 5, "end-index": 5}, nil)
err := SheetInsertDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--end-index") {
t.Fatalf("expected end-index error, got: %v", err)
}
}
func TestSheetInsertDimensionValidateSuccess(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS", "inherit-style": ""},
map[string]int{"start-index": 0, "end-index": 4}, nil)
if err := SheetInsertDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetInsertDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS", "inherit-style": "BEFORE"},
map[string]int{"start-index": 3, "end-index": 7}, nil)
got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `insert_dimension_range`) {
t.Fatalf("DryRun URL missing insert_dimension_range: %s", got)
}
if !strings.Contains(got, `"startIndex":3`) {
t.Fatalf("DryRun missing startIndex: %s", got)
}
if !strings.Contains(got, `"endIndex":7`) {
t.Fatalf("DryRun missing endIndex: %s", got)
}
if !strings.Contains(got, `"inheritStyle":"BEFORE"`) {
t.Fatalf("DryRun missing inheritStyle: %s", got)
}
}
func TestSheetInsertDimensionDryRunNoInheritStyle(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "COLUMNS", "inherit-style": ""},
map[string]int{"start-index": 0, "end-index": 2}, nil)
got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt))
if strings.Contains(got, `inheritStyle`) {
t.Fatalf("DryRun should omit inheritStyle when empty: %s", got)
}
}
func TestSheetInsertDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetInsertDimension, []string{
"+insert-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "3",
"--end-index", "7",
"--inherit-style", "AFTER",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
dim, _ := body["dimension"].(map[string]interface{})
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
t.Fatalf("unexpected dimension: %#v", dim)
}
if body["inheritStyle"] != "AFTER" {
t.Fatalf("unexpected inheritStyle: %v", body["inheritStyle"])
}
}
func TestSheetInsertDimensionExecuteWithoutInheritStyle(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetInsertDimension, []string{
"+insert-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "COLUMNS",
"--start-index", "0",
"--end-index", "2",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
if _, ok := body["inheritStyle"]; ok {
t.Fatalf("inheritStyle should be absent when not specified: %#v", body)
}
}
func TestSheetInsertDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
Status: 400,
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
})
err := mountAndRunSheets(t, SheetInsertDimension, []string{
"+insert-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "0",
"--end-index", "3",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}
// ── UpdateDimension ──────────────────────────────────────────────────────────
func TestSheetUpdateDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50},
map[string]bool{"visible": true})
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateStartIndexLessThan1(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 3, "fixed-size": 50},
map[string]bool{"visible": true})
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--start-index") {
t.Fatalf("expected start-index error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateEndLessThanStart(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 5, "end-index": 3, "fixed-size": 50},
map[string]bool{"visible": true})
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--end-index") {
t.Fatalf("expected end-index error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateNoProperties(t *testing.T) {
t.Parallel()
// Neither --visible nor --fixed-size is set (Changed returns false)
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3}, nil)
// Register the flags but don't set them so Changed() returns false
rt.Cmd.Flags().Bool("visible", false, "")
rt.Cmd.Flags().Int("fixed-size", 0, "")
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--visible or --fixed-size") {
t.Fatalf("expected properties error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateSuccessWithVisible(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3},
map[string]bool{"visible": true})
// Ensure fixed-size flag exists but is not set
rt.Cmd.Flags().Int("fixed-size", 0, "")
if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetUpdateDimensionValidateFixedSizeZero(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 0}, nil)
rt.Cmd.Flags().Bool("visible", false, "")
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") {
t.Fatalf("expected fixed-size error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateFixedSizeNegative(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": -10}, nil)
rt.Cmd.Flags().Bool("visible", false, "")
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") {
t.Fatalf("expected fixed-size error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateSuccessWithFixedSize(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"start-index": 1, "end-index": 5, "fixed-size": 120}, nil)
// Ensure visible flag exists but is not set
rt.Cmd.Flags().Bool("visible", false, "")
if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetUpdateDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50},
map[string]bool{"visible": true})
got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `dimension_range`) {
t.Fatalf("DryRun URL missing dimension_range: %s", got)
}
if !strings.Contains(got, `"visible":true`) {
t.Fatalf("DryRun missing visible: %s", got)
}
if !strings.Contains(got, `"fixedSize":50`) {
t.Fatalf("DryRun missing fixedSize: %s", got)
}
}
func TestSheetUpdateDimensionDryRunOnlyVisible(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3},
map[string]bool{"visible": false})
// Add fixed-size flag but don't set it
rt.Cmd.Flags().Int("fixed-size", 0, "")
got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `"visible":false`) {
t.Fatalf("DryRun missing visible: %s", got)
}
if strings.Contains(got, `fixedSize`) {
t.Fatalf("DryRun should omit fixedSize when not set: %s", got)
}
}
func TestSheetUpdateDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetUpdateDimension, []string{
"+update-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "1",
"--end-index", "3",
"--visible=true",
"--fixed-size", "50",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
props, _ := body["dimensionProperties"].(map[string]interface{})
if props["visible"] != true {
t.Fatalf("expected visible=true, got: %#v", props)
}
if props["fixedSize"] != float64(50) {
t.Fatalf("expected fixedSize=50, got: %#v", props)
}
}
func TestSheetUpdateDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Status: 400,
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
})
err := mountAndRunSheets(t, SheetUpdateDimension, []string{
"+update-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "1",
"--end-index", "3",
"--visible=true",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}
// ── MoveDimension ────────────────────────────────────────────────────────────
func TestSheetMoveDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
err := SheetMoveDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetMoveDimensionValidateNegativeStartIndex(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": -1, "end-index": 1, "destination-index": 4}, nil)
err := SheetMoveDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--start-index") {
t.Fatalf("expected start-index error, got: %v", err)
}
}
func TestSheetMoveDimensionValidateEndLessThanStart(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 5, "end-index": 3, "destination-index": 0}, nil)
err := SheetMoveDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--end-index") {
t.Fatalf("expected end-index error, got: %v", err)
}
}
func TestSheetMoveDimensionValidateNegativeDestinationIndex(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 1, "destination-index": -1}, nil)
err := SheetMoveDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--destination-index") {
t.Fatalf("expected destination-index error, got: %v", err)
}
}
func TestSheetMoveDimensionValidateSuccess(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"start-index": 0, "end-index": 2, "destination-index": 5}, nil)
if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetMoveDimensionValidateWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetMoveDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `move_dimension`) {
t.Fatalf("DryRun URL missing move_dimension: %s", got)
}
if !strings.Contains(got, `"major_dimension":"ROWS"`) {
t.Fatalf("DryRun missing major_dimension: %s", got)
}
if !strings.Contains(got, `"start_index":0`) {
t.Fatalf("DryRun missing start_index: %s", got)
}
if !strings.Contains(got, `"destination_index":4`) {
t.Fatalf("DryRun missing destination_index: %s", got)
}
}
func TestSheetMoveDimensionDryRunWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"},
map[string]int{"start-index": 1, "end-index": 3, "destination-index": 0}, nil)
got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, "shtFromURL") {
t.Fatalf("DryRun should extract token from URL: %s", got)
}
}
func TestSheetMoveDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetMoveDimension, []string{
"+move-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "0",
"--end-index", "1",
"--destination-index", "4",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
source, _ := body["source"].(map[string]interface{})
if source["major_dimension"] != "ROWS" {
t.Fatalf("unexpected major_dimension: %v", source["major_dimension"])
}
if body["destination_index"] != float64(4) {
t.Fatalf("unexpected destination_index: %v", body["destination_index"])
}
}
func TestSheetMoveDimensionExecuteWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/move_dimension",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetMoveDimension, []string{
"+move-dimension",
"--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1",
"--dimension", "COLUMNS",
"--start-index", "1",
"--end-index", "2",
"--destination-index", "0",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetMoveDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension",
Status: 400,
Body: map[string]interface{}{"code": 1310211, "msg": "wrong sheet id"},
})
err := mountAndRunSheets(t, SheetMoveDimension, []string{
"+move-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "0",
"--end-index", "1",
"--destination-index", "4",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}
// ── DeleteDimension ──────────────────────────────────────────────────────────
func TestSheetDeleteDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3}, nil)
err := SheetDeleteDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetDeleteDimensionValidateStartIndexLessThan1(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 3}, nil)
err := SheetDeleteDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--start-index") {
t.Fatalf("expected start-index error, got: %v", err)
}
}
func TestSheetDeleteDimensionValidateEndLessThanStart(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"start-index": 5, "end-index": 3}, nil)
err := SheetDeleteDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--end-index") {
t.Fatalf("expected end-index error, got: %v", err)
}
}
func TestSheetDeleteDimensionValidateSuccess(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 3, "end-index": 7}, nil)
if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetDeleteDimensionValidateWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"start-index": 1, "end-index": 2}, nil)
if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetDeleteDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"start-index": 3, "end-index": 7}, nil)
got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
if !strings.Contains(got, `dimension_range`) {
t.Fatalf("DryRun URL missing dimension_range: %s", got)
}
if !strings.Contains(got, `"startIndex":3`) {
t.Fatalf("DryRun missing startIndex: %s", got)
}
if !strings.Contains(got, `"endIndex":7`) {
t.Fatalf("DryRun missing endIndex: %s", got)
}
}
func TestSheetDeleteDimensionDryRunWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"},
map[string]int{"start-index": 1, "end-index": 5}, nil)
got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, "shtFromURL") {
t.Fatalf("DryRun should extract token from URL: %s", got)
}
}
func TestSheetDeleteDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"delCount": float64(5), "majorDimension": "ROWS"},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
"+delete-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "3",
"--end-index", "7",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"delCount"`) {
t.Fatalf("stdout missing delCount: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
dim, _ := body["dimension"].(map[string]interface{})
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
t.Fatalf("unexpected dimension: %#v", dim)
}
}
func TestSheetDeleteDimensionExecuteWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dimension_range",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"delCount": float64(2), "majorDimension": "COLUMNS"},
},
})
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
"+delete-dimension",
"--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1",
"--dimension", "COLUMNS",
"--start-index", "1",
"--end-index", "2",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetDeleteDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Status: 400,
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
})
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
"+delete-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "3",
"--end-index", "7",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}

View File

@@ -0,0 +1,552 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── SetDropdown ─────────────────────────────────────────────────────────────
func TestSetDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSetDropdownValidateInvalidConditionValues(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": "not-json",
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestSetDropdownValidateNonStringConditionValues(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
}{
{"mixed types", `["ok", 1, null]`},
{"all numbers", `[1, 2, 3]`},
{"null literal", `null`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": tc.input,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must be") {
t.Fatalf("expected validation error for %q, got: %v", tc.input, err)
}
})
}
}
func TestSetDropdownValidateInvalidColors(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "bad-json",
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
t.Fatalf("expected colors JSON error, got: %v", err)
}
}
func TestSetDropdownValidateRangeMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "A2:A100", "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestSetDropdownValidateEmptyConditionValues(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `[]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestSetDropdownValidateColorsMismatchLength(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["a","b","c"]`,
"colors": `["#FF0000"]`,
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors length") {
t.Fatalf("expected length mismatch error, got: %v", err)
}
}
func TestSetDropdownValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
if err := SheetSetDropdown.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSetDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": true, "highlight": false})
got := mustMarshalSheetsDryRun(t, SheetSetDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"POST"`) {
t.Fatalf("DryRun should use POST: %s", got)
}
if !strings.Contains(got, `dataValidation`) {
t.Fatalf("DryRun missing dataValidation: %s", got)
}
if !strings.Contains(got, `"dataValidationType":"list"`) {
t.Fatalf("DryRun missing dataValidationType: %s", got)
}
}
func TestSetDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["opt1","opt2","opt3"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSetDropdownExecuteWithMultipleAndColors(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["a","b"]`,
"--multiple", "--highlight", "--colors", `["#1FB6C1","#F006C2"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
dv, _ := body["dataValidation"].(map[string]interface{})
opts, _ := dv["options"].(map[string]interface{})
if opts["multipleValues"] != true {
t.Fatalf("expected multipleValues=true, got: %v", opts["multipleValues"])
}
if opts["highlightValidData"] != true {
t.Fatalf("expected highlightValidData=true, got: %v", opts["highlightValidData"])
}
}
func TestSetDropdownExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
func TestSetDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── UpdateDropdown ──────────────────────────────────────────────────────────
func TestUpdateDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestUpdateDropdownValidateInvalidRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": "not-json", "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestUpdateDropdownValidateRangesMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `["A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestUpdateDropdownValidateEmptyRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `[]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestUpdateDropdownValidateInvalidColors(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "{not-array}",
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
t.Fatalf("expected colors JSON error, got: %v", err)
}
}
func TestUpdateDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"ranges": `["sheet1!A1:A100"]`, "condition-values": `["new1","new2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
got := mustMarshalSheetsDryRun(t, SheetUpdateDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `sheet1`) {
t.Fatalf("DryRun missing sheet_id: %s", got)
}
}
func TestUpdateDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation/sheet1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"spreadsheetToken": "shtTOKEN", "sheetId": "sheet1",
}},
})
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
"+update-dropdown", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
"--condition-values", `["new1","new2"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation/sheet1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
"+update-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
"--condition-values", `["opt1"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── GetDropdown ─────────────────────────────────────────────────────────────
func TestGetDropdownValidateRangeMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "A2:A100",
}, nil)
err := SheetGetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestGetDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "s1!A2:A100",
}, nil)
err := SheetGetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestGetDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "s1!A2:A100",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
if !strings.Contains(got, `dataValidation`) {
t.Fatalf("DryRun missing dataValidation path: %s", got)
}
}
func TestGetDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
"dataValidations": []interface{}{
map[string]interface{}{
"dataValidationType": "list",
"conditionValues": []interface{}{"opt1", "opt2"},
"ranges": []interface{}{"s1!A2:A100"},
},
},
}},
})
err := mountAndRunSheets(t, SheetGetDropdown, []string{
"+get-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "dataValidations") {
t.Fatalf("stdout missing dataValidations: %s", stdout.String())
}
}
func TestGetDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
"dataValidations": []interface{}{},
}},
})
err := mountAndRunSheets(t, SheetGetDropdown, []string{
"+get-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--range", "s1!A2:A100", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteDropdown ──────────────────────────────────────────────────────────
func TestDeleteDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "ranges": `["s1!A2:A100"]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestDeleteDropdownValidateRangesMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": `["B1:B50"]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestDeleteDropdownValidateEmptyRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": `[]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestDeleteDropdownValidateInvalidRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": "bad",
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestDeleteDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "ranges": `["s1!A2:A100","s1!C1:C50"]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
if !strings.Contains(got, `dataValidationRanges`) {
t.Fatalf("DryRun missing dataValidationRanges: %s", got)
}
}
func TestDeleteDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"rangeResults": []interface{}{
map[string]interface{}{"range": "s1!A2:A100", "success": true, "updatedCells": 99},
},
}},
})
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
"--ranges", `["s1!A2:A100"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "rangeResults") {
t.Fatalf("stdout missing rangeResults: %s", stdout.String())
}
}
func TestDeleteDropdownExecuteMultipleRanges(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
"--ranges", `["s1!A2:A100","s1!C1:C50"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
dvRanges, _ := body["dataValidationRanges"].([]interface{})
if len(dvRanges) != 2 {
t.Fatalf("expected 2 ranges, got: %d", len(dvRanges))
}
}
func TestDeleteDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--ranges", `["s1!A2:A100"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// suppress unused import for bytes in case the test helpers already import it
var _ = (*bytes.Buffer)(nil)

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/tidwall/gjson"
)
func TestSheetExportValidateRejectsURLAndTokenTogether(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, map[string]string{
"url": "https://example.feishu.cn/sheets/shtFromURL",
"spreadsheet-token": "shtTOKEN",
"file-extension": "xlsx",
"output-path": "",
"sheet-id": "",
}, nil, nil)
err := SheetExport.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutual exclusivity error, got: %v", err)
}
}
func TestSheetExportValidateRequiresSheetIDForCSV(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, map[string]string{
"url": "",
"spreadsheet-token": "shtTOKEN",
"file-extension": "csv",
"output-path": "",
"sheet-id": "",
}, nil, nil)
err := SheetExport.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--sheet-id is required when --file-extension is csv") {
t.Fatalf("expected csv sheet-id validation error, got: %v", err)
}
}
func TestSheetExportValidateAllowsCSVWithSheetID(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, map[string]string{
"url": "",
"spreadsheet-token": "shtTOKEN",
"file-extension": "csv",
"output-path": "",
"sheet-id": "sheet1",
}, nil, nil)
if err := SheetExport.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetExportDryRunIncludesSubIDForCSV(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, map[string]string{
"url": "",
"spreadsheet-token": "shtTOKEN",
"file-extension": "csv",
"output-path": "",
"sheet-id": "sheet1",
}, nil, nil)
got := mustMarshalSheetsDryRun(t, SheetExport.DryRun(context.Background(), rt))
if !strings.Contains(got, `"sub_id":"sheet1"`) {
t.Fatalf("DryRun should include sub_id for csv export, got: %s", got)
}
}
func TestSheetExportCommandRejectsInvalidFileExtension(t *testing.T) {
t.Parallel()
f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetExport, []string{
"+export",
"--spreadsheet-token", "shtTOKEN",
"--file-extension", "pdf",
"--as", "user",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), `allowed: xlsx, csv`) {
t.Fatalf("expected invalid file-extension error, got: %v", err)
}
}
func TestSheetExportExecuteWithoutOutputPathReturnsMetadataOnly(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"ticket": "tk_123",
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_123",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"result": map[string]interface{}{
"file_token": "box_123",
},
},
},
})
err := mountAndRunSheets(t, SheetExport, []string{
"+export",
"--spreadsheet-token", "shtTOKEN",
"--file-extension", "xlsx",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := stdout.String()
if gjson.Get(got, "data.file_token").String() != "box_123" || gjson.Get(got, "data.ticket").String() != "tk_123" {
t.Fatalf("stdout should return export metadata, got: %s", got)
}
if strings.Contains(got, `"saved_path"`) {
t.Fatalf("stdout should not include saved_path when --output-path is omitted: %s", got)
}
}

View File

@@ -0,0 +1,673 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── CreateFilterView ─────────────────────────────────────────────────────────
func TestCreateFilterViewValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "range": "s1!A1:H14",
"filter-view-name": "", "filter-view-id": "",
}, nil)
err := SheetCreateFilterView.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestValidateFilterViewTokenRejectsURLAndTokenTogether(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "https://example.feishu.cn/sheets/shtFromURL",
"spreadsheet-token": "shtTOKEN",
"sheet-id": "s1",
"range": "s1!A1:H14",
"filter-view-name": "",
"filter-view-id": "",
}, nil)
_, err := validateFilterViewToken(rt)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutual exclusivity error, got: %v", err)
}
}
func TestCreateFilterViewValidateRejectsEmptyRange(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "range": "",
"filter-view-name": "", "filter-view-id": "",
}, nil)
err := SheetCreateFilterView.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--range must not be empty") {
t.Fatalf("expected empty range error, got: %v", err)
}
}
func TestCreateFilterViewValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "range": "s1!A1:H14",
"filter-view-name": "", "filter-view-id": "",
}, nil)
if err := SheetCreateFilterView.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateFilterViewDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "range": "sheet1!A1:H14",
"filter-view-name": "my view", "filter-view-id": "",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetCreateFilterView.DryRun(context.Background(), rt))
if !strings.Contains(got, `filter_views`) {
t.Fatalf("DryRun URL missing filter_views: %s", got)
}
if !strings.Contains(got, `"filter_view_name":"my view"`) {
t.Fatalf("DryRun missing name: %s", got)
}
}
func TestCreateFilterViewExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "pH9hbVcCXA", "range": "sheet1!A1:H14"},
}},
})
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
"+create-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "filter_view_id") {
t.Fatalf("stdout missing filter_view_id: %s", stdout.String())
}
}
func TestCreateFilterViewExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
"+create-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── UpdateFilterView ─────────────────────────────────────────────────────────
func TestUpdateFilterViewDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"filter-view-id": "pH9hbVcCXA", "range": "sheet1!A1:J20", "filter-view-name": "",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetUpdateFilterView.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PATCH"`) {
t.Fatalf("DryRun should use PATCH: %s", got)
}
if !strings.Contains(got, `pH9hbVcCXA`) {
t.Fatalf("DryRun missing filter_view_id: %s", got)
}
}
func TestUpdateFilterViewExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv123", "range": "sheet1!A1:J20"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
"+update-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--range", "sheet1!A1:J20", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFilterViewRejectsNoFields(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
"+update-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected validation error when no update fields provided, got nil")
}
if !strings.Contains(err.Error(), "at least one") {
t.Fatalf("unexpected error message: %v", err)
}
}
func TestUpdateFilterViewRejectsBlankFieldsOnly(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
"+update-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--range", "", "--filter-view-name", "",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected validation error when only blank update fields are provided, got nil")
}
if !strings.Contains(err.Error(), "at least one") {
t.Fatalf("unexpected error message: %v", err)
}
}
// ── ListFilterViews ──────────────────────────────────────────────────────────
func TestListFilterViewsDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetListFilterViews.DryRun(context.Background(), rt))
if !strings.Contains(got, `filter_views/query`) {
t.Fatalf("DryRun URL missing query: %s", got)
}
}
func TestListFilterViewsExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"filter_view_id": "fv1"}},
}},
})
err := mountAndRunSheets(t, SheetListFilterViews, []string{
"+list-filter-views", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "fv1") {
t.Fatalf("stdout missing fv1: %s", stdout.String())
}
}
// ── GetFilterView ────────────────────────────────────────────────────────────
func TestGetFilterViewDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetFilterView.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
if !strings.Contains(got, `fv123`) {
t.Fatalf("DryRun missing filter_view_id: %s", got)
}
}
func TestGetFilterViewExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv123"},
}},
})
err := mountAndRunSheets(t, SheetGetFilterView, []string{
"+get-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteFilterView ─────────────────────────────────────────────────────────
func TestDeleteFilterViewDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteFilterView.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
}
func TestDeleteFilterViewExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFilterView, []string{
"+delete-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── CreateFilterViewCondition ────────────────────────────────────────────────
func TestCreateFilterViewConditionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "filter-view-id": "fv1",
"condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`,
}, nil)
err := SheetCreateFilterViewCondition.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestCreateFilterViewConditionDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
"condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetCreateFilterViewCondition.DryRun(context.Background(), rt))
if !strings.Contains(got, `conditions`) {
t.Fatalf("DryRun URL missing conditions: %s", got)
}
if !strings.Contains(got, `"condition_id":"E"`) {
t.Fatalf("DryRun missing condition_id: %s", got)
}
if !strings.Contains(got, `"filter_type":"number"`) {
t.Fatalf("DryRun missing filter_type: %s", got)
}
}
func TestCreateFilterViewConditionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "E", "--filter-type", "number", "--compare-type", "less",
"--expected", `["6"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["condition_id"] != "E" {
t.Fatalf("unexpected condition_id: %v", body["condition_id"])
}
}
// ── UpdateFilterViewCondition ────────────────────────────────────────────────
func TestUpdateFilterViewConditionDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
"condition-id": "E", "filter-type": "number", "compare-type": "between", "expected": `["2","10"]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetUpdateFilterViewCondition.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `"compare_type":"between"`) {
t.Fatalf("DryRun missing compare_type: %s", got)
}
}
func TestUpdateFilterViewConditionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
"+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
"--filter-type", "number", "--compare-type", "between", "--expected", `["2","10"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFilterViewConditionRejectsNoFields(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
"+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected validation error when no update fields provided, got nil")
}
if !strings.Contains(err.Error(), "at least one") {
t.Fatalf("unexpected error message: %v", err)
}
}
// ── ListFilterViewConditions ─────────────────────────────────────────────────
func TestListFilterViewConditionsDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetListFilterViewConditions.DryRun(context.Background(), rt))
if !strings.Contains(got, `conditions/query`) {
t.Fatalf("DryRun URL missing conditions/query: %s", got)
}
}
func TestListFilterViewConditionsExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"condition_id": "E"}},
}},
})
err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{
"+list-filter-view-conditions", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── GetFilterViewCondition ───────────────────────────────────────────────────
func TestGetFilterViewConditionDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"filter-view-id": "fv1", "condition-id": "E",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetFilterViewCondition.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
}
func TestGetFilterViewConditionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"},
}},
})
err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{
"+get-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteFilterViewCondition ────────────────────────────────────────────────
func TestDeleteFilterViewConditionDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"filter-view-id": "fv1", "condition-id": "E",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteFilterViewCondition.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
}
func TestDeleteFilterViewConditionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{
"+delete-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── URL flag coverage ────────────────────────────────────────────────────────
func TestCreateFilterViewWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
}},
})
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
"+create-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestListFilterViewsWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
})
err := mountAndRunSheets(t, SheetListFilterViews, []string{
"+list-filter-views", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGetFilterViewWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
}},
})
err := mountAndRunSheets(t, SheetGetFilterView, []string{
"+get-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFilterViewWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
"+update-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--range", "sheet1!A1:J20", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDeleteFilterViewWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFilterView, []string{
"+delete-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateFilterViewConditionWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E"},
}},
})
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
"+create-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "E", "--filter-type", "number", "--compare-type", "less",
"--expected", `["6"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFilterViewConditionWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
"+update-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
"--filter-type", "number", "--expected", `["5"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestListFilterViewConditionsWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
})
err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{
"+list-filter-view-conditions", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGetFilterViewConditionWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E"},
}},
})
err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{
"+get-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDeleteFilterViewConditionWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{
"+delete-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── --expected validation rejects non-array input ────────────────────────────
func TestCreateFilterViewConditionRejectsNonArrayExpected(t *testing.T) {
cases := []struct {
name string
expected string
}{
{"plain string", "hello"},
{"JSON object", `{"key":"val"}`},
{"JSON number", "42"},
{"JSON string", `"hello"`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "A", "--filter-type", "text", "--compare-type", "contains",
"--expected", tc.expected, "--as", "user",
}, f, stdout)
if err == nil {
t.Fatalf("expected validation error for --expected=%q, got nil", tc.expected)
}
if !strings.Contains(err.Error(), "--expected must be a JSON array") {
t.Fatalf("unexpected error message: %v", err)
}
})
}
}

View File

@@ -0,0 +1,524 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── CreateFloatImage ────────────────────────────────────────────────────────
func TestCreateFloatImageValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:A1",
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
}, nil)
err := SheetCreateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestCreateFloatImageValidateSuccess(t *testing.T) {
t.Parallel()
// Pixel flags are int-typed by the shortcut; leave them unset (empty
// intFlags map) so Cmd.Flags().Changed(...) returns false and
// validateFloatImageDims doesn't try to read non-existent ints.
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
}, nil, nil)
if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateFloatImageValidateRejectsMultiCellRange(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:B2",
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
}, nil)
err := SheetCreateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "single cell") {
t.Fatalf("expected single-cell error, got: %v", err)
}
}
func TestCreateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-token": "boxToken", "range": "other!A1:A1",
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
}, nil)
err := SheetCreateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
t.Fatalf("expected sheet-id mismatch error, got: %v", err)
}
}
func TestCreateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) {
t.Parallel()
tests := []struct {
name string
intFlags map[string]int
wantSubst string
}{
{"width below 20", map[string]int{"width": 5}, "--width must be >= 20"},
{"height below 20", map[string]int{"height": 10}, "--height must be >= 20"},
{"negative offset-x", map[string]int{"offset-x": -1}, "--offset-x must be >= 0"},
{"negative offset-y", map[string]int{"offset-y": -5}, "--offset-y must be >= 0"},
}
baseStr := map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil)
err := SheetCreateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
}
})
}
}
func TestCreateFloatImageValidateAcceptsBoundaryDims(t *testing.T) {
t.Parallel()
// Boundary values exactly at the lower bound should pass.
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
},
map[string]int{"width": 20, "height": 20, "offset-x": 0, "offset-y": 0}, nil)
if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil {
t.Fatalf("boundary values should pass, got: %v", err)
}
}
func TestCreateFloatImageDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"float-image-token": "boxToken", "range": "sheet1!A1:A1", "float-image-id": "",
},
map[string]int{"width": 200, "height": 150}, nil)
got := mustMarshalSheetsDryRun(t, SheetCreateFloatImage.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"POST"`) {
t.Fatalf("DryRun should use POST: %s", got)
}
if !strings.Contains(got, `float_images`) {
t.Fatalf("DryRun URL missing float_images: %s", got)
}
if !strings.Contains(got, `"float_image_token":"boxToken"`) {
t.Fatalf("DryRun missing float_image_token: %s", got)
}
if !strings.Contains(got, `"width":200`) || !strings.Contains(got, `"height":150`) {
t.Fatalf("DryRun should emit numeric width/height, got: %s", got)
}
}
func TestCreateFloatImageExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{
"float_image_id": "fi12345678", "float_image_token": "boxToken",
"range": "sheet1!A1:A1", "width": 200, "height": 150,
},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
"+create-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
"--range", "sheet1!A1:A1", "--width", "200", "--height", "150",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "float_image_id") {
t.Fatalf("stdout missing float_image_id: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["float_image_token"] != "boxToken" {
t.Fatalf("unexpected float_image_token: %v", body["float_image_token"])
}
if w, ok := body["width"].(float64); !ok || w != 200 {
t.Fatalf("width should be numeric 200, got %T=%v", body["width"], body["width"])
}
if h, ok := body["height"].(float64); !ok || h != 150 {
t.Fatalf("height should be numeric 150, got %T=%v", body["height"], body["height"])
}
}
func TestCreateFloatImageWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{"float_image_id": "fi12345678"},
}},
})
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
"+create-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
"--range", "sheet1!A1:A1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateFloatImageExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
"+create-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
"--range", "sheet1!A1:A1", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── UpdateFloatImage ────────────────────────────────────────────────────────
func TestUpdateFloatImageValidateRejectsEmptyPayload(t *testing.T) {
t.Parallel()
// Only IDs set, no mutable field: PATCH would be an empty {} body.
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "",
}, nil, nil)
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "specify at least one of --range") {
t.Fatalf("expected empty-payload error, got: %v", err)
}
}
func TestUpdateFloatImageValidateAcceptsSingleField(t *testing.T) {
t.Parallel()
// Any single mutable field should satisfy the payload check.
tests := []struct {
name string
strFlags map[string]string
intFlags map[string]int
}{
{
name: "range only",
strFlags: map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "sheet1!B2:B2",
},
},
{
name: "offset-x only (zero value)",
strFlags: map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "",
},
intFlags: map[string]int{"offset-x": 0},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil)
if err := SheetUpdateFloatImage.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestUpdateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "other!A1:A1",
"width": "", "height": "", "offset-x": "", "offset-y": "",
}, nil)
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
t.Fatalf("expected sheet-id mismatch error, got: %v", err)
}
}
func TestUpdateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) {
t.Parallel()
tests := []struct {
name string
intFlags map[string]int
wantSubst string
}{
{"width below 20", map[string]int{"width": 19}, "--width must be >= 20"},
{"height below 20", map[string]int{"height": 0}, "--height must be >= 20"},
{"negative offset-x", map[string]int{"offset-x": -10}, "--offset-x must be >= 0"},
{"negative offset-y", map[string]int{"offset-y": -1}, "--offset-y must be >= 0"},
}
baseStr := map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "",
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil)
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
}
})
}
}
func TestUpdateFloatImageDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"float-image-id": "fi12345678", "range": "sheet1!B2:B2",
},
map[string]int{"width": 300, "offset-y": 10}, nil)
got := mustMarshalSheetsDryRun(t, SheetUpdateFloatImage.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PATCH"`) {
t.Fatalf("DryRun should use PATCH: %s", got)
}
if !strings.Contains(got, `fi12345678`) {
t.Fatalf("DryRun missing float_image_id: %s", got)
}
if !strings.Contains(got, `"width":300`) || !strings.Contains(got, `"offset_y":10`) {
t.Fatalf("DryRun should emit numeric width/offset_y, got: %s", got)
}
}
func TestUpdateFloatImageExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{"float_image_id": "fi123", "width": 300},
}},
})
err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{
"+update-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-id", "fi123",
"--width", "300", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFloatImageWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{"float_image_id": "fi123"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{
"+update-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--float-image-id", "fi123",
"--range", "sheet1!C3:C3", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── GetFloatImage ───────────────────────────────────────────────────────────
func TestGetFloatImageValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "float-image-id": "fi1",
}, nil)
err := SheetGetFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestGetFloatImageDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetFloatImage.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
if !strings.Contains(got, `fi123`) {
t.Fatalf("DryRun missing float_image_id: %s", got)
}
}
func TestGetFloatImageExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{
"float_image_id": "fi123", "range": "sheet1!A1:A1", "width": 100, "height": 100,
},
}},
})
err := mountAndRunSheets(t, SheetGetFloatImage, []string{
"+get-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "fi123") {
t.Fatalf("stdout missing fi123: %s", stdout.String())
}
}
func TestGetFloatImageWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{"float_image_id": "fi123"},
}},
})
err := mountAndRunSheets(t, SheetGetFloatImage, []string{
"+get-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── ListFloatImages ─────────────────────────────────────────────────────────
func TestListFloatImagesDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetListFloatImages.DryRun(context.Background(), rt))
if !strings.Contains(got, `float_images/query`) {
t.Fatalf("DryRun URL missing query: %s", got)
}
}
func TestListFloatImagesExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"float_image_id": "fi1"},
map[string]interface{}{"float_image_id": "fi2"},
},
}},
})
err := mountAndRunSheets(t, SheetListFloatImages, []string{
"+list-float-images", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "fi1") {
t.Fatalf("stdout missing fi1: %s", stdout.String())
}
}
func TestListFloatImagesWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
})
err := mountAndRunSheets(t, SheetListFloatImages, []string{
"+list-float-images", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteFloatImage ────────────────────────────────────────────────────────
func TestDeleteFloatImageDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteFloatImage.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
}
func TestDeleteFloatImageExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{
"+delete-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDeleteFloatImageWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{
"+delete-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -0,0 +1,702 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/tidwall/gjson"
)
func TestSheetCreateSheetValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "title": "Sheet 2"},
nil, nil)
err := SheetCreateSheet.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetInfoRequiresSpreadsheetMetaAndReadScopes(t *testing.T) {
t.Parallel()
want := []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"}
if !reflect.DeepEqual(SheetInfo.Scopes, want) {
t.Fatalf("SheetInfo scopes = %v, want %v", SheetInfo.Scopes, want)
}
}
func TestSheetManageValidateRejectsURLAndTokenTogether(t *testing.T) {
t.Parallel()
tests := []struct {
name string
shortcut common.Shortcut
args map[string]string
}{
{
name: "create-sheet",
shortcut: SheetCreateSheet,
args: map[string]string{
"url": "https://example.feishu.cn/sheets/shtFromURL",
"spreadsheet-token": "shtTOKEN",
"title": "Data",
},
},
{
name: "copy-sheet",
shortcut: SheetCopySheet,
args: map[string]string{
"url": "https://example.feishu.cn/sheets/shtFromURL",
"spreadsheet-token": "shtTOKEN",
"sheet-id": "sheet1",
"title": "Copy",
},
},
{
name: "delete-sheet",
shortcut: SheetDeleteSheet,
args: map[string]string{
"url": "https://example.feishu.cn/sheets/shtFromURL",
"spreadsheet-token": "shtTOKEN",
"sheet-id": "sheet1",
},
},
{
name: "update-sheet",
shortcut: SheetUpdateSheet,
args: map[string]string{
"url": "https://example.feishu.cn/sheets/shtFromURL",
"spreadsheet-token": "shtTOKEN",
"sheet-id": "sheet1",
"title": "Renamed",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, tt.args, nil, nil)
err := tt.shortcut.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutual exclusivity error, got: %v", err)
}
})
}
}
func TestSheetCreateSheetValidateRejectsInvalidTitle(t *testing.T) {
t.Parallel()
tests := []struct {
name string
title string
wantSubst string
}{
{name: "special chars", title: "bad/title", wantSubst: "must not contain"},
{name: "empty", title: "", wantSubst: "must not be empty"},
{name: "tab", title: "bad\ttitle", wantSubst: "tabs or line breaks"},
{name: "newline", title: "bad\ntitle", wantSubst: "tabs or line breaks"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"spreadsheet-token": "sht1", "title": tt.title},
nil, nil)
err := SheetCreateSheet.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
t.Fatalf("expected title error containing %q, got: %v", tt.wantSubst, err)
}
})
}
}
func TestSheetCreateSheetValidateRejectsNegativeIndexWhenTitleProvided(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"spreadsheet-token": "sht1", "title": "Data"},
map[string]int{"index": -1}, nil)
err := SheetCreateSheet.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--index must be >= 0") {
t.Fatalf("expected index validation error, got: %v", err)
}
}
func TestSheetCopySheetValidateRejectsInvalidTitle(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": "bad\ttitle"},
nil, nil)
err := SheetCopySheet.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "tabs or line breaks") {
t.Fatalf("expected title error, got: %v", err)
}
}
func TestSheetCopySheetValidateRejectsNegativeIndexWhenTitleProvided(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": "Copy"},
map[string]int{"index": -1}, nil)
err := SheetCopySheet.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--index must be >= 0") {
t.Fatalf("expected index validation error, got: %v", err)
}
}
func TestSheetUpdateSheetValidateRejectsEmptyTitle(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": ""},
nil, nil)
err := SheetUpdateSheet.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("expected empty-title error, got: %v", err)
}
}
func TestSheetCreateSheetDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"spreadsheet-token": "shtTOKEN", "title": "Data"},
map[string]int{"index": 0}, nil)
got := mustMarshalSheetsDryRun(t, SheetCreateSheet.DryRun(context.Background(), rt))
if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) {
t.Fatalf("DryRun URL mismatch: %s", got)
}
if !strings.Contains(got, `"addSheet"`) || !strings.Contains(got, `"title":"Data"`) || !strings.Contains(got, `"index":0`) {
t.Fatalf("DryRun body mismatch: %s", got)
}
}
func TestSheetCreateSheetExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"replies": []interface{}{
map[string]interface{}{
"addSheet": map[string]interface{}{
"properties": map[string]interface{}{
"sheetId": "sheet_new",
"title": "Data",
"index": 0,
},
},
},
},
},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetCreateSheet, []string{
"+create-sheet",
"--spreadsheet-token", "shtTOKEN",
"--title", "Data",
"--index", "0",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet_new" {
t.Fatalf("stdout missing sheet_id: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
requests, _ := body["requests"].([]interface{})
if len(requests) != 1 {
t.Fatalf("unexpected body: %#v", body)
}
req0, _ := requests[0].(map[string]interface{})
addSheet, _ := req0["addSheet"].(map[string]interface{})
props, _ := addSheet["properties"].(map[string]interface{})
if props["title"] != "Data" {
t.Fatalf("request title = %#v", props["title"])
}
if idx, ok := props["index"].(float64); !ok || idx != 0 {
t.Fatalf("request index = %#v", props["index"])
}
}
func TestSheetCopySheetValidateMissingSheetID(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"spreadsheet-token": "sht1", "sheet-id": ""},
nil, nil)
err := SheetCopySheet.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--sheet-id") {
t.Fatalf("expected sheet-id error, got: %v", err)
}
}
func TestSheetCopySheetDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "title": "Copy"},
map[string]int{"index": 2}, nil)
got := mustMarshalSheetsDryRun(t, SheetCopySheet.DryRun(context.Background(), rt))
if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) {
t.Fatalf("DryRun URL mismatch: %s", got)
}
if !strings.Contains(got, `"copySheet"`) || !strings.Contains(got, `"sheetId":"sheet1"`) || !strings.Contains(got, `"title":"Copy"`) {
t.Fatalf("DryRun body mismatch: %s", got)
}
if !strings.Contains(got, `"[2] Move copied sheet to requested index"`) || !strings.Contains(got, `\u003ccopied_sheet_id\u003e`) || !strings.Contains(got, `"index":2`) {
t.Fatalf("DryRun should describe follow-up move: %s", got)
}
}
func TestSheetCopySheetExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
copyStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"replies": []interface{}{
map[string]interface{}{
"copySheet": map[string]interface{}{
"properties": map[string]interface{}{
"sheetId": "sheet_copy",
"title": "Copy",
"index": 1,
},
},
},
},
},
},
}
moveStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"replies": []interface{}{
map[string]interface{}{
"updateSheet": map[string]interface{}{
"properties": map[string]interface{}{
"sheetId": "sheet_copy",
"index": 2,
},
},
},
},
},
},
}
reg.Register(copyStub)
reg.Register(moveStub)
err := mountAndRunSheets(t, SheetCopySheet, []string{
"+copy-sheet",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--title", "Copy",
"--index", "2",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet_copy" {
t.Fatalf("stdout missing copied sheet id: %s", stdout.String())
}
if gjson.Get(stdout.String(), "data.sheet.index").Int() != 2 {
t.Fatalf("stdout missing moved index: %s", stdout.String())
}
var copyBody map[string]interface{}
if err := json.Unmarshal(copyStub.CapturedBody, &copyBody); err != nil {
t.Fatalf("parse copy body: %v", err)
}
if !strings.Contains(string(copyStub.CapturedBody), `"copySheet"`) {
t.Fatalf("copy request missing copySheet: %s", string(copyStub.CapturedBody))
}
if !strings.Contains(string(moveStub.CapturedBody), `"updateSheet"`) || !strings.Contains(string(moveStub.CapturedBody), `"index":2`) {
t.Fatalf("move request mismatch: %s", string(moveStub.CapturedBody))
}
}
func TestSheetCopySheetExecuteMoveFailureIncludesCopiedSheetRecovery(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"replies": []interface{}{
map[string]interface{}{
"copySheet": map[string]interface{}{
"properties": map[string]interface{}{
"sheetId": "sheet_copy",
"title": "Copy",
"index": 1,
},
},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update",
Status: 400,
Body: map[string]interface{}{
"code": 1310211,
"msg": "wrong sheet id",
"error": map[string]interface{}{
"log_id": "log-move-failed",
},
},
})
err := mountAndRunSheets(t, SheetCopySheet, []string{
"+copy-sheet",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--title", "Copy",
"--index", "2",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected move failure, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError with detail, got %T: %v", err, err)
}
if exitErr.Detail.Code != 1310211 {
t.Fatalf("error code = %d, want 1310211", exitErr.Detail.Code)
}
if !strings.Contains(exitErr.Detail.Message, `sheet copied successfully as "sheet_copy"`) {
t.Fatalf("message missing copied sheet id: %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "do not retry +copy-sheet") {
t.Fatalf("hint missing retry guard: %q", exitErr.Detail.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "+update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2") {
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["partial_success"] != true {
t.Fatalf("partial_success = %#v, want true", detail["partial_success"])
}
if detail["sheet_id"] != "sheet_copy" {
t.Fatalf("sheet_id = %#v, want %q", detail["sheet_id"], "sheet_copy")
}
if detail["requested_index"] != 2 {
t.Fatalf("requested_index = %#v, want 2", detail["requested_index"])
}
if detail["retry_command"] != "lark-cli sheets +update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2" {
t.Fatalf("retry_command = %#v", detail["retry_command"])
}
if detail["log_id"] != "log-move-failed" {
t.Fatalf("log_id = %#v, want %q", detail["log_id"], "log-move-failed")
}
}
func TestSheetDeleteSheetDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1"},
nil, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteSheet.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"POST"`) {
t.Fatalf("DryRun should use POST: %s", got)
}
if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) {
t.Fatalf("DryRun URL mismatch: %s", got)
}
if !strings.Contains(got, `"deleteSheet"`) || !strings.Contains(got, `"sheetId":"sheet1"`) {
t.Fatalf("DryRun body mismatch: %s", got)
}
}
func TestSheetDeleteSheetExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"replies": []interface{}{
map[string]interface{}{
"deleteSheet": map[string]interface{}{
"result": true,
"sheetId": "sheet1",
},
},
},
},
},
})
err := mountAndRunSheets(t, SheetDeleteSheet, []string{
"+delete-sheet",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--yes",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gjson.Get(stdout.String(), "data.deleted").Bool() {
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
}
if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet1" {
t.Fatalf("stdout missing sheet_id: %s", stdout.String())
}
}
func TestSheetUpdateSheetValidateRequiresMutation(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1"},
nil, nil)
err := SheetUpdateSheet.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "specify at least one") {
t.Fatalf("expected mutation error, got: %v", err)
}
}
func TestSheetUpdateSheetValidateRejectsBadProtectionConfig(t *testing.T) {
t.Parallel()
tests := []struct {
name string
strFlags map[string]string
intFlags map[string]int
wantSubst string
}{
{
name: "lock-info requires lock",
strFlags: map[string]string{
"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "lock-info": "private",
},
wantSubst: "--lock when updating protection settings",
},
{
name: "user-ids requires user-id-type",
strFlags: map[string]string{
"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "lock": "LOCK",
"user-ids": `["ou_1"]`,
},
wantSubst: "--user-ids requires --user-id-type",
},
{
name: "negative frozen rows rejected",
strFlags: map[string]string{
"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1",
},
intFlags: map[string]int{"frozen-row-count": -1},
wantSubst: "--frozen-row-count must be >= 0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil)
err := SheetUpdateSheet.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
}
})
}
}
func TestSheetUpdateSheetDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{
"spreadsheet-token": "shtTOKEN",
"sheet-id": "sheet1",
"title": "Hidden Sheet",
"lock": "LOCK",
"lock-info": "private",
"user-ids": `["ou_1"]`,
"user-id-type": "open_id",
},
map[string]int{
"index": 3,
"frozen-row-count": 2,
"frozen-col-count": 1,
},
map[string]bool{"hidden": false},
)
got := mustMarshalSheetsDryRun(t, SheetUpdateSheet.DryRun(context.Background(), rt))
for _, want := range []string{
`"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`,
`"user_id_type":"open_id"`,
`"sheetId":"sheet1"`,
`"title":"Hidden Sheet"`,
`"index":3`,
`"hidden":false`,
`"frozenRowCount":2`,
`"frozenColCount":1`,
`"lock":"LOCK"`,
`"lockInfo":"private"`,
`"userIDs":["ou_1"]`,
} {
if !strings.Contains(got, want) {
t.Fatalf("DryRun missing %s: %s", want, got)
}
}
}
func TestSheetUpdateSheetExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update?user_id_type=open_id",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"replies": []interface{}{
map[string]interface{}{
"updateSheet": map[string]interface{}{
"properties": map[string]interface{}{
"sheetId": "sheet1",
"title": "Renamed",
"index": 1,
"hidden": true,
"frozenRowCount": 2,
"frozenColCount": 1,
"protect": map[string]interface{}{
"lock": "LOCK",
"lockInfo": "private",
"userIDs": []interface{}{"ou_1"},
},
},
},
},
},
},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetUpdateSheet, []string{
"+update-sheet",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--title", "Renamed",
"--index", "1",
"--hidden=true",
"--frozen-row-count", "2",
"--frozen-col-count", "1",
"--lock", "LOCK",
"--lock-info", "private",
"--user-ids", `["ou_1"]`,
"--user-id-type", "open_id",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet1" {
t.Fatalf("stdout missing sheet_id: %s", stdout.String())
}
if gjson.Get(stdout.String(), "data.sheet.title").String() != "Renamed" {
t.Fatalf("stdout missing title: %s", stdout.String())
}
if gjson.Get(stdout.String(), "data.sheet.grid_properties.frozen_row_count").Int() != 2 {
t.Fatalf("stdout missing frozen_row_count: %s", stdout.String())
}
if gjson.Get(stdout.String(), "data.sheet.protect.lock_info").String() != "private" {
t.Fatalf("stdout missing lock_info: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
requests, ok := body["requests"].([]interface{})
if !ok || len(requests) != 1 {
t.Fatalf("unexpected requests body: %#v", body)
}
req0, _ := requests[0].(map[string]interface{})
updateSheet, _ := req0["updateSheet"].(map[string]interface{})
props, _ := updateSheet["properties"].(map[string]interface{})
if props["sheetId"] != "sheet1" || props["title"] != "Renamed" {
t.Fatalf("unexpected properties: %#v", props)
}
}
func TestBuildUpdateSheetOutputOmitsBlankTitleWhenTitleNotChanged(t *testing.T) {
t.Parallel()
out, ok := buildUpdateSheetOutput("shtTOKEN", map[string]interface{}{
"replies": []interface{}{
map[string]interface{}{
"updateSheet": map[string]interface{}{
"properties": map[string]interface{}{
"sheetId": "sheet1",
"title": "",
"hidden": false,
"frozenRowCount": 0,
},
},
},
},
}, false)
if !ok {
t.Fatal("expected output")
}
sheet, _ := out["sheet"].(map[string]interface{})
if _, exists := sheet["title"]; exists {
t.Fatalf("blank title should be omitted when title is unchanged: %#v", sheet)
}
if sheet["sheet_id"] != "sheet1" {
t.Fatalf("unexpected sheet output: %#v", sheet)
}
}

View File

@@ -0,0 +1,721 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var sheetProtectLockValues = []string{"LOCK", "UNLOCK"}
func sheetBatchUpdatePath(token string) string {
return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/sheets_batch_update", validate.EncodePathSegment(token))
}
func validateSheetManageToken(runtime *common.RuntimeContext) (string, error) {
if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil {
return "", err
}
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
return "", common.FlagErrorf("%v", err)
}
return token, nil
}
url := strings.TrimSpace(runtime.Str("url"))
if url == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
token := extractSpreadsheetToken(url)
if token == "" || token == url {
return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/<token>")
}
if err := validate.RejectControlChars(token, "url"); err != nil {
return "", common.FlagErrorf("%v", err)
}
return token, nil
}
func validateSheetID(flagName, sheetID string) error {
if strings.TrimSpace(sheetID) == "" {
return common.FlagErrorf("specify --%s", flagName)
}
if err := validate.RejectControlChars(sheetID, flagName); err != nil {
return common.FlagErrorf("%v", err)
}
return nil
}
func validateSheetTitle(flagName, title string) error {
if title == "" {
return common.FlagErrorf("--%s must not be empty", flagName)
}
if strings.ContainsAny(title, "\t\r\n") {
return common.FlagErrorf("--%s must not contain tabs or line breaks", flagName)
}
if err := validate.RejectControlChars(title, flagName); err != nil {
return common.FlagErrorf("%v", err)
}
if len([]rune(title)) > 100 {
return common.FlagErrorf("--%s must be <= 100 characters", flagName)
}
if strings.ContainsAny(title, `/\?*[]:`) || strings.Contains(title, `\`) {
return common.FlagErrorf("--%s must not contain any of / \\ ? * [ ] :", flagName)
}
return nil
}
func validateNonNegativeInt(flagName string, value int) error {
if value < 0 {
return common.FlagErrorf("--%s must be >= 0, got %d", flagName, value)
}
return nil
}
func buildSheetCreateProperties(runtime *common.RuntimeContext) map[string]interface{} {
properties := map[string]interface{}{}
if runtime.Changed("title") {
properties["title"] = runtime.Str("title")
}
if runtime.Changed("index") {
properties["index"] = runtime.Int("index")
}
return properties
}
func buildCreateSheetBody(runtime *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"requests": []interface{}{
map[string]interface{}{
"addSheet": map[string]interface{}{
"properties": buildSheetCreateProperties(runtime),
},
},
},
}
}
func buildCopySheetBody(runtime *common.RuntimeContext) map[string]interface{} {
copySheet := map[string]interface{}{
"source": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
},
}
if runtime.Changed("title") {
copySheet["destination"] = map[string]interface{}{
"title": runtime.Str("title"),
}
}
return map[string]interface{}{
"requests": []interface{}{
map[string]interface{}{
"copySheet": copySheet,
},
},
}
}
func buildDeleteSheetBody(sheetID string) map[string]interface{} {
return map[string]interface{}{
"requests": []interface{}{
map[string]interface{}{
"deleteSheet": map[string]interface{}{
"sheetId": sheetID,
},
},
},
}
}
func buildMoveCopiedSheetBody(sheetID string, index int) map[string]interface{} {
return map[string]interface{}{
"requests": []interface{}{
map[string]interface{}{
"updateSheet": map[string]interface{}{
"properties": map[string]interface{}{
"sheetId": sheetID,
"index": index,
},
},
},
},
}
}
func normalizeSheetProperties(properties map[string]interface{}, titleChanged bool) map[string]interface{} {
sheet := map[string]interface{}{}
if v, ok := properties["sheetId"]; ok {
sheet["sheet_id"] = v
}
if v, ok := properties["title"]; ok {
if title, ok := v.(string); !ok || title != "" || titleChanged {
sheet["title"] = v
}
}
if v, ok := properties["index"]; ok {
sheet["index"] = v
}
if v, ok := properties["hidden"]; ok {
sheet["hidden"] = v
}
grid := map[string]interface{}{}
if v, ok := properties["frozenRowCount"]; ok {
grid["frozen_row_count"] = v
}
if v, ok := properties["frozenColCount"]; ok {
grid["frozen_column_count"] = v
}
if len(grid) > 0 {
sheet["grid_properties"] = grid
}
if protect, ok := properties["protect"].(map[string]interface{}); ok {
outProtect := map[string]interface{}{}
if v, ok := protect["lock"]; ok {
outProtect["lock"] = v
}
if v, ok := protect["lockInfo"]; ok {
outProtect["lock_info"] = v
}
if v, ok := protect["userIDs"]; ok {
outProtect["user_ids"] = v
}
if len(outProtect) > 0 {
sheet["protect"] = outProtect
}
}
return sheet
}
func firstReply(data map[string]interface{}) (map[string]interface{}, bool) {
replies, ok := data["replies"].([]interface{})
if !ok || len(replies) == 0 {
return nil, false
}
reply, ok := replies[0].(map[string]interface{})
if !ok {
return nil, false
}
return reply, true
}
func buildOperateSheetOutput(token string, data map[string]interface{}, opKey string, titleChanged bool) (map[string]interface{}, bool) {
reply, ok := firstReply(data)
if !ok {
return nil, false
}
op, ok := reply[opKey].(map[string]interface{})
if !ok {
return nil, false
}
properties, ok := op["properties"].(map[string]interface{})
if !ok {
return nil, false
}
sheet := normalizeSheetProperties(properties, titleChanged)
out := map[string]interface{}{
"spreadsheet_token": token,
"sheet": sheet,
}
if sheetID, ok := sheet["sheet_id"].(string); ok && sheetID != "" {
out["sheet_id"] = sheetID
}
return out, true
}
func buildDeleteSheetOutput(token string, sheetID string, data map[string]interface{}) (map[string]interface{}, bool) {
reply, ok := firstReply(data)
if !ok {
return nil, false
}
del, ok := reply["deleteSheet"].(map[string]interface{})
if !ok {
return nil, false
}
out := map[string]interface{}{
"spreadsheet_token": token,
"sheet_id": sheetID,
"deleted": true,
}
if v, ok := del["sheetId"].(string); ok && v != "" {
out["sheet_id"] = v
}
if v, ok := del["result"].(bool); ok {
out["deleted"] = v
}
return out, true
}
func mergeSheetOutputs(base, overlay map[string]interface{}) map[string]interface{} {
if base == nil {
return overlay
}
if overlay == nil {
return base
}
out := map[string]interface{}{}
for k, v := range base {
out[k] = v
}
for k, v := range overlay {
if k == "sheet" {
baseSheet, _ := out["sheet"].(map[string]interface{})
overlaySheet, _ := v.(map[string]interface{})
mergedSheet := map[string]interface{}{}
for sk, sv := range baseSheet {
mergedSheet[sk] = sv
}
for sk, sv := range overlaySheet {
mergedSheet[sk] = sv
}
out["sheet"] = mergedSheet
continue
}
out[k] = v
}
return out
}
func mergeSheetErrorDetail(detail interface{}, overlay map[string]interface{}) interface{} {
if len(overlay) == 0 {
return detail
}
if detail == nil {
return overlay
}
if existing, ok := detail.(map[string]interface{}); ok {
merged := map[string]interface{}{}
for k, v := range existing {
merged[k] = v
}
for k, v := range overlay {
merged[k] = v
}
return merged
}
merged := map[string]interface{}{}
for k, v := range overlay {
merged[k] = v
}
merged["cause_detail"] = detail
return merged
}
func copySheetMoveRetryCommand(token, sheetID string, index int) string {
return fmt.Sprintf("lark-cli sheets +update-sheet --spreadsheet-token %s --sheet-id %s --index %d", token, sheetID, index)
}
func wrapCopySheetMoveError(err error, token, sheetID string, index int) error {
if strings.TrimSpace(sheetID) == "" {
return err
}
retryCommand := copySheetMoveRetryCommand(token, sheetID, index)
msg := fmt.Sprintf("sheet copied successfully as %q, but moving it to index %d failed", sheetID, index)
hint := fmt.Sprintf(
"do not retry +copy-sheet: the new sheet already exists as %s\nretry only the move with: %s",
sheetID,
retryCommand,
)
detail := map[string]interface{}{
"partial_success": true,
"failed_step": "move_copied_sheet",
"spreadsheet_token": token,
"sheet_id": sheetID,
"requested_index": index,
"retry_command": retryCommand,
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
if upstreamHint := strings.TrimSpace(exitErr.Detail.Hint); upstreamHint != "" {
hint = upstreamHint + "\n" + hint
}
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: fmt.Sprintf("%s: %s", msg, exitErr.Detail.Message),
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: mergeSheetErrorDetail(exitErr.Detail.Detail, detail),
},
Err: err,
Raw: exitErr.Raw,
}
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Message: fmt.Sprintf("%s: %v", msg, err),
Hint: hint,
Detail: detail,
},
Err: err,
}
}
func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
if err := validateSheetID("sheet-id", runtime.Str("sheet-id")); err != nil {
return err
}
if runtime.Changed("title") {
if err := validateSheetTitle("title", runtime.Str("title")); err != nil {
return err
}
}
if runtime.Changed("index") {
if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil {
return err
}
}
if runtime.Changed("frozen-row-count") {
if err := validateNonNegativeInt("frozen-row-count", runtime.Int("frozen-row-count")); err != nil {
return err
}
}
if runtime.Changed("frozen-col-count") {
if err := validateNonNegativeInt("frozen-col-count", runtime.Int("frozen-col-count")); err != nil {
return err
}
}
if runtime.Changed("lock-info") {
if err := validate.RejectControlChars(runtime.Str("lock-info"), "lock-info"); err != nil {
return common.FlagErrorf("%v", err)
}
}
hasProtectConfig := runtime.Changed("lock") || runtime.Changed("lock-info") || runtime.Changed("user-ids")
if hasProtectConfig {
lock := runtime.Str("lock")
if !runtime.Changed("lock") {
return common.FlagErrorf("specify --lock when updating protection settings")
}
if runtime.Changed("lock-info") && lock != "LOCK" {
return common.FlagErrorf("--lock-info requires --lock LOCK")
}
if runtime.Changed("user-ids") {
if lock != "LOCK" {
return common.FlagErrorf("--user-ids requires --lock LOCK")
}
if runtime.Str("user-id-type") == "" {
return common.FlagErrorf("--user-ids requires --user-id-type")
}
userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids"))
if err != nil {
return err
}
if len(userIDs) == 0 {
return common.FlagErrorf("--user-ids must not be empty")
}
}
}
hasUpdate := runtime.Changed("title") ||
runtime.Changed("index") ||
runtime.Changed("hidden") ||
runtime.Changed("frozen-row-count") ||
runtime.Changed("frozen-col-count") ||
hasProtectConfig
if !hasUpdate {
return common.FlagErrorf("specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids")
}
return nil
}
func buildUpdateSheetBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
properties := map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
}
if runtime.Changed("title") {
properties["title"] = runtime.Str("title")
}
if runtime.Changed("index") {
properties["index"] = runtime.Int("index")
}
if runtime.Changed("hidden") {
properties["hidden"] = runtime.Bool("hidden")
}
if runtime.Changed("frozen-row-count") {
properties["frozenRowCount"] = runtime.Int("frozen-row-count")
}
if runtime.Changed("frozen-col-count") {
properties["frozenColCount"] = runtime.Int("frozen-col-count")
}
if runtime.Changed("lock") || runtime.Changed("lock-info") || runtime.Changed("user-ids") {
protect := map[string]interface{}{
"lock": runtime.Str("lock"),
}
if runtime.Changed("lock-info") {
protect["lockInfo"] = runtime.Str("lock-info")
}
if runtime.Changed("user-ids") {
userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids"))
if err != nil {
return nil, err
}
protect["userIDs"] = userIDs
}
properties["protect"] = protect
}
return map[string]interface{}{
"requests": []interface{}{
map[string]interface{}{
"updateSheet": map[string]interface{}{
"properties": properties,
},
},
},
}, nil
}
func buildUpdateSheetOutput(token string, data map[string]interface{}, titleChanged bool) (map[string]interface{}, bool) {
return buildOperateSheetOutput(token, data, "updateSheet", titleChanged)
}
var SheetCreateSheet = common.Shortcut{
Service: "sheets",
Command: "+create-sheet",
Description: "Create a sheet in an existing spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "title", Desc: "sheet title"},
{Name: "index", Type: "int", Desc: "sheet index (0-based)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if runtime.Changed("title") {
if err := validateSheetTitle("title", runtime.Str("title")); err != nil {
return err
}
}
if runtime.Changed("index") {
if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil {
return err
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update").
Body(buildCreateSheetBody(runtime)).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime))
if err != nil {
return err
}
if out, ok := buildOperateSheetOutput(token, data, "addSheet", runtime.Changed("title")); ok {
runtime.Out(out, nil)
return nil
}
runtime.Out(data, nil)
return nil
},
}
var SheetCopySheet = common.Shortcut{
Service: "sheets",
Command: "+copy-sheet",
Description: "Copy a sheet within a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "source sheet ID", Required: true},
{Name: "title", Desc: "new sheet title"},
{Name: "index", Type: "int", Desc: "new sheet index (0-based)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if err := validateSheetID("sheet-id", runtime.Str("sheet-id")); err != nil {
return err
}
if runtime.Changed("title") {
if err := validateSheetTitle("title", runtime.Str("title")); err != nil {
return err
}
}
if runtime.Changed("index") {
if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil {
return err
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
dry := common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update").
Desc("[1] Copy sheet").
Body(buildCopySheetBody(runtime)).
Set("token", token)
if runtime.Changed("index") {
dry.POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update").
Desc("[2] Move copied sheet to requested index").
Body(buildMoveCopiedSheetBody("<copied_sheet_id>", runtime.Int("index"))).
Set("token", token)
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime))
if err != nil {
return err
}
out, ok := buildOperateSheetOutput(token, data, "copySheet", runtime.Changed("title"))
if !ok {
runtime.Out(data, nil)
return nil
}
if runtime.Changed("index") {
copiedSheetID, _ := out["sheet_id"].(string)
moveResp, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index")))
if err != nil {
return wrapCopySheetMoveError(err, token, copiedSheetID, runtime.Int("index"))
}
if moveOut, ok := buildUpdateSheetOutput(token, moveResp, false); ok {
out = mergeSheetOutputs(out, moveOut)
}
}
runtime.Out(out, nil)
return nil
},
}
var SheetDeleteSheet = common.Shortcut{
Service: "sheets",
Command: "+delete-sheet",
Description: "Delete a sheet from a spreadsheet",
Risk: "high-risk-write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID to delete", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
return validateSheetID("sheet-id", runtime.Str("sheet-id"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update").
Body(buildDeleteSheetBody(runtime.Str("sheet-id"))).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id")))
if err != nil {
return err
}
if out, ok := buildDeleteSheetOutput(token, runtime.Str("sheet-id"), data); ok {
runtime.Out(out, nil)
return nil
}
runtime.Out(data, nil)
return nil
},
}
var SheetUpdateSheet = common.Shortcut{
Service: "sheets",
Command: "+update-sheet",
Description: "Update sheet properties",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "title", Desc: "sheet title"},
{Name: "index", Type: "int", Desc: "sheet index (0-based)"},
{Name: "hidden", Type: "bool", Desc: "set true to hide or false to unhide"},
{Name: "frozen-row-count", Type: "int", Desc: "freeze rows through this count (0 unfreezes)"},
{Name: "frozen-col-count", Type: "int", Desc: "freeze columns through this count (0 unfreezes)"},
{Name: "lock", Desc: "sheet protection mode", Enum: sheetProtectLockValues},
{Name: "lock-info", Desc: "protection remark"},
{Name: "user-ids", Desc: `extra editor IDs for protected sheet as JSON array (e.g. '["ou_xxx"]')`},
{Name: "user-id-type", Desc: "user ID type for --user-ids", Enum: []string{"open_id", "union_id", "lark_id", "user_id"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
return validateUpdateSheetFlags(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
body, _ := buildUpdateSheetBody(runtime)
dry := common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update").
Body(body).
Set("token", token)
if userIDType := runtime.Str("user-id-type"); userIDType != "" {
dry.Params(map[string]interface{}{"user_id_type": userIDType})
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
body, err := buildUpdateSheetBody(runtime)
if err != nil {
return err
}
var params map[string]interface{}
if userIDType := runtime.Str("user-id-type"); userIDType != "" {
params = map[string]interface{}{"user_id_type": userIDType}
}
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), params, body)
if err != nil {
return err
}
if out, ok := buildUpdateSheetOutput(token, data, runtime.Changed("title")); ok {
runtime.Out(out, nil)
return nil
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,272 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"bytes"
"encoding/json"
"mime"
"mime/multipart"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestSheetMediaUploadValidateMissingToken(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload", "--file", "img.png", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetMediaUploadValidateMissingFileBeforeDryRun(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "missing.png",
"--dry-run", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "file not found") {
t.Fatalf("expected file-not-found error before dry-run planning, got: %v", err)
}
}
func TestSheetMediaUploadValidateRejectsDirectoryBeforeDryRun(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
if err := os.Mkdir("imgdir", 0o755); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "imgdir",
"--dry-run", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "regular file") {
t.Fatalf("expected regular-file error before dry-run planning, got: %v", err)
}
}
func TestSheetMediaUploadDryRunSmallFile(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "img.png",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
}
if !strings.Contains(out, `"sheet_image"`) {
t.Fatalf("dry-run should include parent_type=sheet_image, got: %s", out)
}
if strings.Contains(out, "upload_prepare") {
t.Fatalf("dry-run should not use multipart for small file, got: %s", out)
}
}
func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
if err := os.WriteFile("img.png", []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--url", "https://example.feishu.cn/sheets/shtFromURL?sheet=abc",
"--file", "img.png",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "shtFromURL") {
t.Fatalf("dry-run should extract token from URL, got: %s", stdout.String())
}
}
func TestSheetMediaUploadDryRunLargeFileUsesMultipart(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
// Sparse file: 20MB + 1 byte, triggers multipart path without allocating disk.
largeFile, err := os.Create("big.png")
if err != nil {
t.Fatal(err)
}
if err := largeFile.Truncate(20*1024*1024 + 1); err != nil {
t.Fatal(err)
}
_ = largeFile.Close()
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err = mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "big.png",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/drive/v1/medias/upload_prepare",
"/open-apis/drive/v1/medias/upload_part",
"/open-apis/drive/v1/medias/upload_finish",
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run should include %q for large file, got: %s", want, out)
}
}
if strings.Contains(out, "upload_all") {
t.Fatalf("dry-run should not use upload_all for large file, got: %s", out)
}
}
func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "boxTOK123"},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "img.png",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("parse output: %v", err)
}
data, _ := envelope["data"].(map[string]interface{})
if data["file_token"] != "boxTOK123" {
t.Fatalf("file_token = %v, want boxTOK123", data["file_token"])
}
if data["spreadsheet_token"] != "shtSTUB" {
t.Fatalf("spreadsheet_token = %v, want shtSTUB", data["spreadsheet_token"])
}
body := decodeSheetsMultipartBody(t, stub)
if got := body.Fields["parent_type"]; got != sheetImageParentType {
t.Fatalf("parent_type = %q, want %q", got, sheetImageParentType)
}
if got := body.Fields["parent_node"]; got != "shtSTUB" {
t.Fatalf("parent_node = %q, want shtSTUB", got)
}
if got := body.Fields["file_name"]; got != "img.png" {
t.Fatalf("file_name = %q, want img.png", got)
}
if got := body.Fields["size"]; got != "9" {
t.Fatalf("size = %q, want 9 (len of png-bytes)", got)
}
}
func TestSheetMediaUploadFileNotFound(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "missing.png",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") {
t.Fatalf("err = %v, want file-not-found error", err)
}
}
// withSheetsTestWorkingDir chdirs to dir for this test. Not compatible with
// t.Parallel — chdir is process-wide.
func withSheetsTestWorkingDir(t *testing.T, dir string) {
t.Helper()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
}
type capturedSheetsMultipart struct {
Fields map[string]string
Files map[string][]byte
}
func decodeSheetsMultipartBody(t *testing.T, stub *httpmock.Stub) capturedSheetsMultipart {
t.Helper()
contentType := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
t.Fatalf("parse content-type %q: %v", contentType, err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
}
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
body := capturedSheetsMultipart{Fields: map[string]string{}, Files: map[string][]byte{}}
for {
part, err := reader.NextPart()
if err != nil {
break
}
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(part)
if part.FileName() != "" {
body.Files[part.FormName()] = buf.Bytes()
continue
}
body.Fields[part.FormName()] = buf.String()
}
return body
}

View File

@@ -0,0 +1,322 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func mustMarshalSheetsDryRun(t *testing.T, v interface{}) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
return string(b)
}
func newSheetsTestRuntime(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
for name := range stringFlags {
cmd.Flags().String(name, "", "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, value := range stringFlags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, value := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[value]); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestNormalizeSheetRangeSeparators(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
}{
{name: "standard", input: "sheet_123!A1:B2", want: "sheet_123!A1:B2"},
{name: "escaped ascii", input: `sheet_123\!A1:B2`, want: "sheet_123!A1:B2"},
{name: "fullwidth", input: "sheet_123A1:B2", want: "sheet_123!A1:B2"},
{name: "escaped fullwidth", input: `sheet_123\A1:B2`, want: "sheet_123!A1:B2"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := normalizeSheetRangeSeparators(tt.input); got != tt.want {
t.Fatalf("normalizeSheetRangeSeparators(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestValidateSheetRangeInputAcceptsEscapedSeparator(t *testing.T) {
t.Parallel()
if err := validateSheetRangeInput("", `sheet_123\A1:B2`); err != nil {
t.Fatalf("validateSheetRangeInput() error = %v, want nil", err)
}
}
func TestSheetReadDryRunNormalizesEscapedSeparator(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "sht_test",
"range": `sheet_123\A1`,
"sheet-id": "",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetRead.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"range":"sheet_123!A1:A1"`) {
t.Fatalf("SheetRead.DryRun() = %s, want normalized escaped separator", got)
}
}
func TestSheetWriteDryRunNormalizesEscapedSeparator(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "sht_test",
"range": `sheet_123\A1:B2`,
"values": `[[1,2],[3,4]]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetWrite.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) {
t.Fatalf("SheetWrite.DryRun() = %s, want normalized escaped separator", got)
}
}
func TestSheetAppendDryRunNormalizesEscapedSeparator(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "sht_test",
"range": `sheet_123\A1:B2`,
"values": `[["foo","bar"]]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetAppend.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) {
t.Fatalf("SheetAppend.DryRun() = %s, want normalized escaped separator", got)
}
}
// A bare sheet selector (no --range) must pass through verbatim. Sheet ids
// that look A1-ish (letters+digits) would otherwise be mangled by the range
// normalizer into "<id>!<id>:<id>".
func TestSheetReadDryRunSheetOnlyVerbatim(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "sht_test",
"range": "",
"sheet-id": "shtABC123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetRead.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"range":"shtABC123"`) {
t.Fatalf("SheetRead.DryRun() = %s, want bare sheet id verbatim", got)
}
}
func TestSheetWriteDryRunSheetOnlyBuildsRect(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "sht_test",
"range": "",
"sheet-id": "shtABC123",
"values": `[["x"]]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetWrite.DryRun(context.Background(), runtime))
// Built from the sheet's A1 (A1:A1 for a 1x1 write), NOT the mangled
// "shtABC123!shtABC123:shtABC123" that piping a bare id through the
// range normalizer produced.
if !strings.Contains(got, `"range":"shtABC123!A1:A1"`) {
t.Fatalf("SheetWrite.DryRun() = %s, want rect built from sheet A1", got)
}
}
func TestSheetAppendDryRunSheetOnlyVerbatim(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "sht_test",
"range": "",
"sheet-id": "shtABC123",
"values": `[["foo"]]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetAppend.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"range":"shtABC123"`) {
t.Fatalf("SheetAppend.DryRun() = %s, want bare sheet id verbatim", got)
}
}
func TestSheetFindDryRunNormalizesEscapedSeparator(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "sht_test",
"sheet-id": "sheet_123",
"find": "target",
"range": `sheet_123\A1:B2`,
}, map[string]bool{
"ignore-case": false,
"match-entire-cell": false,
"search-by-regex": false,
"include-formulas": false,
})
got := mustMarshalSheetsDryRun(t, SheetFind.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) {
t.Fatalf("SheetFind.DryRun() = %s, want normalized escaped separator", got)
}
}
func TestSheetFindValidateMismatchedRangeSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "target",
"range": "sheet2!A1:B2",
}, map[string]bool{
"ignore-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false,
})
err := SheetFind.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
t.Fatalf("expected mismatch error, got: %v", err)
}
}
func TestCellDataValidateRejectsURLAndTokenTogether(t *testing.T) {
t.Parallel()
tests := []struct {
name string
shortcut common.Shortcut
strFlags map[string]string
boolFlags map[string]bool
}{
{
name: "read",
shortcut: SheetRead,
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN"},
},
{
name: "write",
shortcut: SheetWrite,
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "values": `[[1]]`},
},
{
name: "append",
shortcut: SheetAppend,
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "values": `[[1]]`},
},
{
name: "find",
shortcut: SheetFind,
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "find": "x"},
boolFlags: map[string]bool{"ignore-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false},
},
{
name: "replace",
shortcut: SheetReplace,
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "find": "a", "replacement": "b"},
boolFlags: map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, tt.strFlags, tt.boolFlags)
err := tt.shortcut.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutual exclusivity error, got: %v", err)
}
})
}
}
func TestCellDataValidateRejectsInvalidSpreadsheetURL(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "https://example.feishu.cn/docx/doxcnNotSheet",
"spreadsheet-token": "",
}, nil)
err := SheetRead.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "spreadsheet URL") {
t.Fatalf("expected invalid spreadsheet URL error, got: %v", err)
}
}
func TestCellDataValidateRejectsNon2DValues(t *testing.T) {
t.Parallel()
tests := []struct {
name string
shortcut common.Shortcut
strFlags map[string]string
}{
{
name: "write 1d array",
shortcut: SheetWrite,
strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `[1,2]`},
},
{
name: "write object",
shortcut: SheetWrite,
strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `{"a":1}`},
},
{
name: "append string",
shortcut: SheetAppend,
strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `"x"`},
},
{
name: "append null",
shortcut: SheetAppend,
strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `null`},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, tt.strFlags, nil)
err := tt.shortcut.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "must be a 2D array") {
t.Fatalf("expected 2D-array validation error, got: %v", err)
}
})
}
}

View File

@@ -0,0 +1,632 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"bytes"
"context"
"encoding/json"
"io"
"io/fs"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func sheetsTestConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "sheets-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
}
func mountAndRunSheets(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "sheets"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
type sheetWriteImageStaticFileIOProvider struct {
fio fileio.FileIO
}
func (p *sheetWriteImageStaticFileIOProvider) Name() string { return "sheet-write-image-static" }
func (p *sheetWriteImageStaticFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO {
return p.fio
}
type sheetWriteImageMemoryFileIO struct {
files map[string][]byte
}
func (f *sheetWriteImageMemoryFileIO) Open(name string) (fileio.File, error) {
data, ok := f.files[name]
if !ok {
return nil, os.ErrNotExist
}
return sheetWriteImageMemoryFile{Reader: bytes.NewReader(data)}, nil
}
func (f *sheetWriteImageMemoryFileIO) Stat(name string) (fileio.FileInfo, error) {
data, ok := f.files[name]
if !ok {
return nil, os.ErrNotExist
}
return sheetWriteImageFileInfo{size: int64(len(data))}, nil
}
func (f *sheetWriteImageMemoryFileIO) ResolvePath(path string) (string, error) { return path, nil }
func (f *sheetWriteImageMemoryFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
return nil, nil
}
type sheetWriteImageMemoryFile struct {
*bytes.Reader
}
func (sheetWriteImageMemoryFile) Close() error { return nil }
type sheetWriteImageFileInfo struct {
size int64
}
func (i sheetWriteImageFileInfo) Size() int64 { return i.size }
func (i sheetWriteImageFileInfo) IsDir() bool { return false }
func (i sheetWriteImageFileInfo) Mode() fs.FileMode { return 0 }
const existingWriteImageTestFile = "./lark_sheets_cell_images.go"
// ── Validate ─────────────────────────────────────────────────────────────────
func TestSheetWriteImageValidateRequiresToken(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"image": "./logo.png",
"range": "A1",
}, nil)
err := SheetWriteImage.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetWriteImageValidateAcceptsURL(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"url": "https://example.larksuite.com/sheets/shtABC123",
"image": existingWriteImageTestFile,
"range": "sheetId!A1:A1",
"sheet-id": "",
}, nil)
err := SheetWriteImage.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetWriteImageValidateAcceptsSpreadsheetToken(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "shtABC123",
"image": existingWriteImageTestFile,
"range": "sheetId!A1:A1",
"sheet-id": "",
}, nil)
err := SheetWriteImage.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetWriteImageValidateRejectsRelativeRangeWithoutSheetID(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "shtABC123",
"image": "./logo.png",
"range": "A1",
"sheet-id": "",
}, nil)
err := SheetWriteImage.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--sheet-id") {
t.Fatalf("expected sheet-id error, got: %v", err)
}
}
func TestSheetWriteImageValidateAcceptsRelativeRangeWithSheetID(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "shtABC123",
"image": existingWriteImageTestFile,
"range": "A1",
"sheet-id": "sheet1",
}, nil)
err := SheetWriteImage.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetWriteImageValidateRejectsMultiCellRange(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "shtABC123",
"image": "./logo.png",
"range": "sheet1!A1:B2",
"sheet-id": "",
}, nil)
err := SheetWriteImage.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "single cell") {
t.Fatalf("expected single cell error, got: %v", err)
}
}
func TestSheetWriteImageValidateAcceptsSameCellSpan(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "shtABC123",
"image": existingWriteImageTestFile,
"range": "sheet1!A1:A1",
"sheet-id": "",
}, nil)
err := SheetWriteImage.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DryRun ───────────────────────────────────────────────────────────────────
func TestSheetWriteImageDryRun(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "sht_test",
"range": "sheet1!B2",
"sheet-id": "",
"image": "./chart.png",
"name": "",
"url": "",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"range":"sheet1!B2:B2"`) {
t.Fatalf("DryRun range not normalized: %s", got)
}
if !strings.Contains(got, `"name":"chart.png"`) {
t.Fatalf("DryRun name not derived from image path: %s", got)
}
// JSON escapes < and > to \u003c and \u003e.
if !strings.Contains(got, `binary: ./chart.png`) {
t.Fatalf("DryRun image field not showing binary placeholder: %s", got)
}
if !strings.Contains(got, `"description":"JSON upload with inline image bytes"`) {
t.Fatalf("DryRun description incorrect: %s", got)
}
}
func TestSheetWriteImageDryRunCustomName(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "sht_test",
"range": "sheet1!A1:A1",
"sheet-id": "",
"image": "./output.png",
"name": "revenue_chart.png",
"url": "",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"name":"revenue_chart.png"`) {
t.Fatalf("DryRun should use custom name: %s", got)
}
}
func TestSheetWriteImageDryRunUsesURL(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "",
"range": "sheet1!C3",
"sheet-id": "",
"image": "./logo.png",
"name": "",
"url": "https://example.larksuite.com/sheets/shtFromURL",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime))
if !strings.Contains(got, `shtFromURL`) {
t.Fatalf("DryRun should extract token from URL: %s", got)
}
if !strings.Contains(got, `"range":"sheet1!C3:C3"`) {
t.Fatalf("DryRun range not normalized: %s", got)
}
}
func TestSheetWriteImageDryRunWithSheetID(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "sht_test",
"range": "A1",
"sheet-id": "mySheet",
"image": "./img.png",
"name": "",
"url": "",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"range":"mySheet!A1:A1"`) {
t.Fatalf("DryRun should normalize relative range with sheet-id: %s", got)
}
}
func TestSheetWriteImageDryRunDoesNotValidateImageFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "/__bridge_url__/qKrk1wSAtS",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run should not stat or open image files, got: %v", err)
}
if !strings.Contains(stdout.String(), "/__bridge_url__/qKrk1wSAtS") {
t.Fatalf("dry-run output should preserve image path: %s", stdout.String())
}
}
// ── Execute ──────────────────────────────────────────────────────────────────
func TestSheetWriteImageExecuteSendsJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"spreadsheetToken": "shtTOKEN",
"revision": float64(5),
"updateRange": "sheet1!A1:A1",
},
},
}
reg.Register(stub)
tmpDir := t.TempDir()
cmdutil.TestChdir(t, tmpDir)
// Create a small test image file.
imgData := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic bytes
if err := os.WriteFile("test.png", imgData, 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "./test.png",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the request was sent as JSON (not multipart/form-data).
if stub.CapturedHeaders == nil {
t.Fatal("request headers not captured")
}
ct := stub.CapturedHeaders.Get("Content-Type")
if !strings.Contains(ct, "application/json") {
t.Fatalf("Content-Type = %q, want application/json", ct)
}
// Verify the captured body contains the image as base64 in JSON.
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("request body is not valid JSON: %v", err)
}
if body["range"] != "sheet1!A1:A1" {
t.Fatalf("body range = %v, want sheet1!A1:A1", body["range"])
}
if body["name"] != "test.png" {
t.Fatalf("body name = %v, want test.png", body["name"])
}
if body["image"] == nil {
t.Fatal("body image field is nil")
}
// Verify output contains expected fields.
if !strings.Contains(stdout.String(), "spreadsheetToken") {
t.Fatalf("stdout missing spreadsheetToken: %s", stdout.String())
}
}
func TestSheetWriteImageExecuteUsesFileIOForBridgeSentinelPath(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
imagePath := "/__bridge_url__/qKrk1wSAtS"
imageData := []byte{0x89, 0x50, 0x4E, 0x47}
f.FileIOProvider = &sheetWriteImageStaticFileIOProvider{
fio: &sheetWriteImageMemoryFileIO{
files: map[string][]byte{imagePath: imageData},
},
}
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"spreadsheetToken": "shtTOKEN",
"revision": float64(5),
"updateRange": "sheet1!A1:A1",
},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", imagePath,
"--name", "bridge.png",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("request body is not valid JSON: %v", err)
}
if body["name"] != "bridge.png" {
t.Fatalf("body name = %v, want bridge.png", body["name"])
}
if body["image"] == nil {
t.Fatal("body image field is nil")
}
}
func TestSheetWriteImageExecuteRejectsNonexistentFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
tmpDir := t.TempDir()
cmdutil.TestChdir(t, tmpDir)
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "./nonexistent.png",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error for nonexistent file, got nil")
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetWriteImageExecuteRejectsDirectory(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
tmpDir := t.TempDir()
cmdutil.TestChdir(t, tmpDir)
// Create a directory where the image path points.
if err := os.Mkdir("not_a_file", 0755); err != nil {
t.Fatalf("Mkdir() error: %v", err)
}
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "./not_a_file",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error for directory, got nil")
}
if !strings.Contains(err.Error(), "regular file") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetWriteImageExecuteWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/values_image",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"spreadsheetToken": "shtFromURL",
"revision": float64(1),
"updateRange": "sheet1!B2:B2",
},
},
}
reg.Register(stub)
tmpDir := t.TempDir()
cmdutil.TestChdir(t, tmpDir)
if err := os.WriteFile("pic.png", []byte{0x89, 0x50, 0x4E, 0x47}, 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--url", "https://example.larksuite.com/sheets/shtFromURL",
"--range", "sheet1!B2:B2",
"--image", "./pic.png",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "shtFromURL") {
t.Fatalf("stdout missing token: %s", stdout.String())
}
}
func TestSheetWriteImageExecuteCustomName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"spreadsheetToken": "shtTOKEN",
"revision": float64(2),
"updateRange": "sheet1!A1:A1",
},
},
}
reg.Register(stub)
tmpDir := t.TempDir()
cmdutil.TestChdir(t, tmpDir)
if err := os.WriteFile("raw.png", []byte{0x89, 0x50, 0x4E, 0x47}, 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "./raw.png",
"--name", "custom_chart.png",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("request body is not valid JSON: %v", err)
}
if body["name"] != "custom_chart.png" {
t.Fatalf("body name = %v, want custom_chart.png", body["name"])
}
}
func TestSheetWriteImageExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image",
Status: 400,
Body: map[string]interface{}{
"code": 90001,
"msg": "invalid range",
},
})
tmpDir := t.TempDir()
cmdutil.TestChdir(t, tmpDir)
if err := os.WriteFile("bad.png", []byte{0x89, 0x50}, 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "./bad.png",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}
func TestSheetWriteImageExecuteRejectsOversizedFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
tmpDir := t.TempDir()
cmdutil.TestChdir(t, tmpDir)
// Create a sparse file that reports > 20MB without writing actual data.
fh, err := os.Create("huge.png")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(21 * 1024 * 1024); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
fh.Close()
err = mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "./huge.png",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error for oversized file, got nil")
}
if !strings.Contains(err.Error(), "exceeds 20MB limit") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetWriteImageExecuteRejectsAbsolutePath(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
tmpDir := t.TempDir()
cmdutil.TestChdir(t, tmpDir)
if err := os.WriteFile("abs.png", []byte{0x89, 0x50}, 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "/etc/passwd",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error for absolute path, got nil")
}
if !strings.Contains(err.Error(), "unsafe image path") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -0,0 +1,323 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetInfo = common.Shortcut{
Service: "sheets",
Command: "+info",
Description: "View spreadsheet metadata and sheet information",
Risk: "read",
Scopes: []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token").
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
spreadsheetData, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil)
if err != nil {
return err
}
var sheetsData interface{}
sheetsResult, sheetsErr := runtime.RawAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(token)), nil, nil)
if sheetsErr == nil {
if sheetsMap, ok := sheetsResult.(map[string]interface{}); ok {
if d, ok := sheetsMap["data"].(map[string]interface{}); ok {
sheetsData = d
}
}
}
runtime.Out(map[string]interface{}{
"spreadsheet": spreadsheetData,
"sheets": sheetsData,
}, nil)
return nil
},
}
var SheetCreate = common.Shortcut{
Service: "sheets",
Command: "+create",
Description: "Create a spreadsheet (optional header row and initial data)",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "title", Desc: "spreadsheet title", Required: true},
{Name: "folder-token", Desc: "target folder token"},
{Name: "headers", Desc: "header row JSON array"},
{Name: "data", Desc: "initial data JSON 2D array"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if headersStr := runtime.Str("headers"); headersStr != "" {
var headers []interface{}
if err := json.Unmarshal([]byte(headersStr), &headers); err != nil {
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
}
}
if dataStr := runtime.Str("data"); dataStr != "" {
var rows [][]interface{}
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := map[string]interface{}{"title": runtime.Str("title")}
if folderToken := runtime.Str("folder-token"); folderToken != "" {
body["folder_token"] = folderToken
}
d := common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets").
Body(body)
if runtime.IsBot() {
d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new spreadsheet.")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
title := runtime.Str("title")
folderToken := runtime.Str("folder-token")
headersStr := runtime.Str("headers")
dataStr := runtime.Str("data")
var allRows []interface{}
if headersStr != "" {
var headers []interface{}
if err := json.Unmarshal([]byte(headersStr), &headers); err != nil {
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
}
if len(headers) > 0 {
allRows = append(allRows, any(headers))
}
}
if dataStr != "" {
var rows []interface{}
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
}
if len(rows) > 0 {
allRows = append(allRows, rows...)
}
}
createData := map[string]interface{}{"title": title}
if folderToken != "" {
createData["folder_token"] = folderToken
}
data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData)
if err != nil {
return err
}
spreadsheet, _ := data["spreadsheet"].(map[string]interface{})
token, _ := spreadsheet["spreadsheet_token"].(string)
if len(allRows) > 0 && token != "" {
appendRange, err := getFirstSheetID(runtime, token)
if err != nil {
return err
}
if _, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"valueRange": map[string]interface{}{
"range": appendRange,
"values": allRows,
},
}); err != nil {
return err
}
}
out := map[string]interface{}{
"spreadsheet_token": token,
"title": title,
}
url, _ := spreadsheet["url"].(string)
if url = strings.TrimSpace(url); url != "" {
out["url"] = url
} else if u := common.BuildResourceURL(runtime.Config.Brand, "sheet", token); u != "" {
out["url"] = u
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil {
out["permission_grant"] = grant
}
runtime.Out(out, nil)
return nil
},
}
var SheetExport = common.Shortcut{
Service: "sheets",
Command: "+export",
Description: "Export a spreadsheet (async task polling + optional download)",
Risk: "read",
Scopes: []string{"docs:document:export", "drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "file-extension", Desc: "export format: xlsx | csv", Required: true, Enum: []string{"xlsx", "csv"}},
{Name: "output-path", Desc: "local save path"},
{Name: "sheet-id", Desc: "sheet ID (required for CSV)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if runtime.Str("file-extension") == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" {
return common.FlagErrorf("--sheet-id is required when --file-extension is csv")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
body := map[string]interface{}{
"token": token,
"type": "sheet",
"file_extension": runtime.Str("file-extension"),
}
if sheetID := strings.TrimSpace(runtime.Str("sheet-id")); sheetID != "" {
body["sub_id"] = sheetID
}
return common.NewDryRunAPI().
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Set("token", token).Set("ext", runtime.Str("file-extension"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
fileExt := runtime.Str("file-extension")
outputPath := runtime.Str("output-path")
sheetID := runtime.Str("sheet-id")
if outputPath != "" {
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
}
exportData := map[string]interface{}{
"token": token,
"type": "sheet",
"file_extension": fileExt,
}
if sheetID != "" {
exportData["sub_id"] = sheetID
}
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, exportData)
if err != nil {
return err
}
ticket, _ := data["ticket"].(string)
fmt.Fprintf(runtime.IO().ErrOut, "Waiting for export task to complete...\n")
var fileToken string
for i := 0; i < 50; i++ {
time.Sleep(600 * time.Millisecond)
pollResult, err := runtime.RawAPI("GET", "/open-apis/drive/v1/export_tasks/"+ticket, map[string]interface{}{"token": token}, nil)
if err != nil {
continue
}
pollMap, _ := pollResult.(map[string]interface{})
pollData, _ := pollMap["data"].(map[string]interface{})
pollResult2, _ := pollData["result"].(map[string]interface{})
if pollResult2 != nil {
ft, _ := pollResult2["file_token"].(string)
if ft != "" {
fileToken = ft
break
}
}
}
if fileToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "export task timed out")
}
fmt.Fprintf(runtime.IO().ErrOut, "Export complete: file_token=%s\n", fileToken)
if outputPath == "" {
runtime.Out(map[string]interface{}{
"file_token": fileToken,
"ticket": ticket,
}, nil)
return nil
}
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download failed: %s", err)
}
defer resp.Body.Close()
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
}
savedPath, _ := runtime.ResolveSavePath(outputPath)
if savedPath == "" {
savedPath = outputPath
}
runtime.Out(map[string]interface{}{
"saved_path": savedPath,
"size_bytes": result.Size(),
}, nil)
return nil
},
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all sheets shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
// Spreadsheet management
SheetCreate,
SheetInfo,
SheetExport,
// Sheet management
SheetCreateSheet,
SheetCopySheet,
SheetDeleteSheet,
SheetUpdateSheet,
// Cell data
SheetRead,
SheetWrite,
SheetAppend,
SheetFind,
SheetReplace,
// Cell style and merge
SheetSetStyle,
SheetBatchSetStyle,
SheetMergeCells,
SheetUnmergeCells,
// Cell images
SheetWriteImage,
// Row/column management
SheetAddDimension,
SheetInsertDimension,
SheetUpdateDimension,
SheetMoveDimension,
SheetDeleteDimension,
// Filter views
SheetCreateFilterView,
SheetUpdateFilterView,
SheetListFilterViews,
SheetGetFilterView,
SheetDeleteFilterView,
SheetCreateFilterViewCondition,
SheetUpdateFilterViewCondition,
SheetListFilterViewConditions,
SheetGetFilterViewCondition,
SheetDeleteFilterViewCondition,
// Dropdown
SheetSetDropdown,
SheetUpdateDropdown,
SheetGetDropdown,
SheetDeleteDropdown,
// Float images
SheetMediaUpload,
SheetCreateFloatImage,
SheetUpdateFloatImage,
SheetGetFloatImage,
SheetListFloatImages,
SheetDeleteFloatImage,
}
}

View File

@@ -278,7 +278,6 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
}
for _, tc := range cases {
tc := tc
t.Run(tc.shortcut, func(t *testing.T) {
t.Parallel()
@@ -424,7 +423,6 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
@@ -469,6 +467,120 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
}
}
// TestBatchOp_RejectsWrongScalarType locks the type-check that closes the
// silent-coercion gap: `operations` skips parse-time schema validation, and
// mapFlagView coerces a mismatched scalar to its zero value, so a sub-op field
// whose JSON type contradicts its flag-defs type must be rejected up front
// rather than landing as 0 / false in the wrong place.
func TestBatchOp_RejectsWrongScalarType(t *testing.T) {
t.Parallel()
cases := []struct {
name string
subShortcut string
subInput string
wantContains string
}{
{
name: "int flag given a string",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":2,"index":"abc"}`,
wantContains: "--index must be a number",
},
{
name: "int flag given a boolean",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":true,"index":0}`,
wantContains: "--source-index must be a number",
},
{
name: "bool flag given a string",
subShortcut: "+cells-set",
subInput: `{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]],"allow-overwrite":"true"}`,
wantContains: "--allow-overwrite must be a boolean",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translateBatchOp accepted wrong-typed field; want error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}
// TestBatchOp_GuardsBeyondCobra locks the two batch sub-ops whose standalone
// required-flag enforcement lives OUTSIDE the shared *Input builder — so it is
// invisible to TestBatchOp_ErrorEquivalence and was missed by the refactor:
// - +csv-put: standalone requires one-of(start-cell, range) via cobra's
// MarkFlagsOneRequired (PostMount); a batch sub-op never runs cobra.
// - +sheet-move: standalone requires --index (>=0) and source-index>=0 in
// SheetMove.Validate; the batch path uses a dedicated builder.
//
// Without an explicit guard, mapFlagView's flag-default fallback silently wins
// (start-cell→"A1", index→0), so the batch sub-op diverges from the standalone
// contract instead of failing.
func TestBatchOp_GuardsBeyondCobra(t *testing.T) {
t.Parallel()
cases := []struct {
name string
subShortcut string
subInput string
wantContains string
}{
{
name: "+csv-put without start-cell or range",
subShortcut: "+csv-put",
subInput: `{"sheet-id":"sh1","csv":"a,b"}`,
wantContains: "--start-cell or --range is required",
},
{
name: "+sheet-move without index",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":2}`,
wantContains: "requires index",
},
{
name: "+sheet-move negative index",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":2,"index":-1}`,
wantContains: "--index must be >= 0",
},
{
name: "+sheet-move negative source-index",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":-1,"index":0}`,
wantContains: "--source-index must be >= 0",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translateBatchOp accepted bad input; want error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}
// TestBatchOp_RejectsBadSubOpInput pins down the secondary guard: for
// inputs that cobra's MarkFlagRequired catches on the standalone path,
// the +batch-update sub-op (which has no cobra layer) must still reject
@@ -584,7 +696,6 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
@@ -651,7 +762,6 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
@@ -714,3 +824,75 @@ func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) {
t.Errorf("+rows-resize resize_height wrong: %#v", resizeIn)
}
}
// TestBatchOp_RequiredFlagParity is the systematic standalone-vs-batch parity
// contract: for EVERY batchable shortcut, a +batch-update sub-op that satisfies
// the sheet locator but omits all of the shortcut's business-required flags must
// fail in translateBatchOp — never silently fall back to a default. The earlier
// cases (TestBatchOp_ErrorEquivalence / GuardsBeyondCobra) cover hand-picked
// shortcuts; this one is data-driven over batchOpDispatch + flag-defs, so it
// guards the whole surface and auto-covers any shortcut added later. If a future
// refactor moves a required check out of the shared *Input builder (the exact
// failure mode behind the csv-put / sheet-move gaps), the corresponding sub-op
// would start accepting missing args and this test fails.
func TestBatchOp_RequiredFlagParity(t *testing.T) {
t.Parallel()
defs, err := loadFlagDefs()
if err != nil {
t.Fatalf("loadFlagDefs: %v", err)
}
// Flags supplied by the +batch-update top level (url/token), or that form the
// sub-op's own sheet selector, are context — not "business" inputs.
locator := map[string]bool{
"url": true, "spreadsheet-token": true,
"sheet-id": true, "sheet-name": true,
"target-sheet-id": true, "target-sheet-name": true,
}
// How each command expresses its sheet locator in a sub-op, so the error we
// trigger is the business one, not a missing-locator error.
sheetSel := func(cmd string) map[string]interface{} {
switch cmd {
case "+sheet-create": // create needs no existing-sheet anchor
return map[string]interface{}{}
case "+pivot-create": // placement selector is target-sheet-*; data source is --source
return map[string]interface{}{"target-sheet-id": "sh1"}
default:
return map[string]interface{}{"sheet-id": "sh1"}
}
}
for cmd := range batchOpDispatch {
spec, ok := defs[cmd]
if !ok {
t.Errorf("%s is in batchOpDispatch but has no flag-defs entry", cmd)
continue
}
var business []string
for _, fl := range spec.Flags {
if fl.Kind == "system" || locator[fl.Name] {
continue
}
if fl.Required == "required" || fl.Required == "xor" {
business = append(business, fl.Name)
}
}
if len(business) == 0 {
continue // only-locator commands (sheet-delete/hide/unhide/copy/filter-delete): nothing to omit
}
t.Run(cmd, func(t *testing.T) {
t.Parallel()
rawOp := map[string]interface{}{"shortcut": cmd, "input": sheetSel(cmd)}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Errorf("%s: a sub-op omitting business-required %v was accepted; want an error "+
"(batch must reject missing required flags, not silently default)", cmd, business)
return
}
// The sub-op DID supply a sheet selector, so a missing-locator error
// would mean the fixture is wrong and the business-required check never
// actually ran — reject that shape so the parity check stays honest.
if strings.Contains(err.Error(), "specify at least one of") {
t.Errorf("%s: got a missing-locator error, not a business-required one (fixture bug): %v", cmd, err)
}
})
}
}

View File

@@ -213,6 +213,19 @@ func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[str
if !fv.Changed("source-index") {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)")
}
if fv.Int("source-index") < 0 {
return nil, common.FlagErrorf("--source-index must be >= 0")
}
// Standalone +sheet-move requires --index (see SheetMove.Validate). A batch
// sub-op skips that path, and mapFlagView falls back to the flag default (0),
// which would silently move the sheet to the front. Require it explicitly so
// the batch contract matches the standalone one.
if !fv.Changed("index") {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires index")
}
if fv.Int("index") < 0 {
return nil, common.FlagErrorf("--index must be >= 0")
}
return map[string]interface{}{
"excel_id": token,
"operation": "move",
@@ -293,6 +306,12 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
}
}
fv := newMapFlagViewForCommand(sc, input)
// operations is skipped by parse-time schema validation, so type-check the
// sub-op's scalar fields here before the translator reads them via
// Int/Bool/Float64 (which would otherwise coerce a wrong type to zero).
if err := fv.validateRawTypes(); err != nil {
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
}
sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc)
sheetID := strings.TrimSpace(fv.Str(sheetIDFlag))
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
@@ -321,12 +340,3 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
}
return out, nil
}
// 仅供测试 / 调试:暴露已知 shortcut 列表,便于做 enum 漂移对账。
func batchOpDispatchKeys() []string {
keys := make([]string, 0, len(batchOpDispatch))
for k := range batchOpDispatch {
keys = append(keys, k)
}
return keys
}

View File

@@ -3,7 +3,10 @@
package sheets
import "testing"
import (
"strings"
"testing"
)
// +csv-put locates with --start-cell, while +csv-get / +cells-set locate with
// --range. Agents routinely carry --range over to +csv-put and hit a guaranteed
@@ -35,16 +38,20 @@ func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) {
}
}
// With neither --start-cell nor --range set, +csv-put keeps its existing
// behavior: --start-cell defaults to A1, so the paste anchors at A1.
func TestCsvPutInput_DefaultsToA1(t *testing.T) {
// With neither --start-cell nor --range explicitly set, csvPutInput rejects the
// call instead of silently anchoring at the "A1" flag default. Standalone never
// reaches this path — cobra's MarkFlagsOneRequired(start-cell, range) catches it
// first — but a +batch-update sub-op skips cobra, so the guard must live in the
// shared builder too. Otherwise a batch +csv-put with no anchor silently pastes
// at A1, diverging from the standalone contract.
func TestCsvPutInput_RequiresStartCellOrRange(t *testing.T) {
fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{"csv": "a,b"})
input, err := csvPutInput(fv, "tok", "sid", "")
if err != nil {
t.Fatalf("csvPutInput returned error: %v", err)
_, err := csvPutInput(fv, "tok", "sid", "")
if err == nil {
t.Fatal("csvPutInput accepted missing start-cell/range; want a required-flag error")
}
if got, _ := input["start_cell"].(string); got != "A1" {
t.Errorf("start_cell = %q, want %q (default)", got, "A1")
if !strings.Contains(err.Error(), "--start-cell or --range is required") {
t.Errorf("error = %q, want it to mention '--start-cell or --range is required'", err.Error())
}
}

View File

@@ -9,6 +9,7 @@ import (
"testing"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
@@ -94,6 +95,34 @@ func TestExecute_SheetMove_LookupsIndex(t *testing.T) {
}
}
// TestExecute_SheetMove_LookupsIndexByTitle covers the same lookup path as
// above but with get_workbook_structure exposing the display name as "title"
// (the field the real tool returns) instead of "sheet_name". lookupSheetIndex
// must resolve --sheet-name against either key.
func TestExecute_SheetMove_LookupsIndexByTitle(t *testing.T) {
t.Parallel()
lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"汇总","index":3}]}`)
move := toolOutputStub(testToken, "write", `{"sheet_id":"sh1"}`)
out, err := runShortcutWithStubs(t, SheetMove,
[]string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"},
lookup, move,
)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
if move.CapturedBody == nil {
t.Fatal("move stub didn't capture a body")
}
body := decodeRawEnvelopeBody(t, move.CapturedBody)
input := decodeToolInput(t, body, "modify_workbook_structure")
if input["sheet_id"] != "sh1" {
t.Errorf("sheet_id = %v, want sh1 (resolved from --sheet-name via title)", input["sheet_id"])
}
if input["source_index"].(float64) != 3 {
t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"])
}
}
// TestExecute_CellsGet covers a multi-range read end-to-end.
func TestExecute_CellsGet(t *testing.T) {
t.Parallel()
@@ -271,6 +300,52 @@ func TestExecute_BatchUpdate_Translated(t *testing.T) {
}
}
// TestExecute_BatchUpdate_ContinueOnErrorPrecedence locks the flag-vs-envelope
// precedence: an explicit --continue-on-error=false must keep the strict
// transaction even when the --operations envelope carries continue_on_error:true,
// while an envelope value still applies when the flag is absent. Guards against
// the regression where the flag was read by value (runtime.Bool) rather than by
// Changed().
func TestExecute_BatchUpdate_ContinueOnErrorPrecedence(t *testing.T) {
t.Parallel()
envelope := `{"operations":[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}],"continue_on_error":true}`
t.Run("explicit false overrides envelope", func(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
"--url", testURL,
"--operations", envelope,
"--continue-on-error=false",
"--yes",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update")
if input["continue_on_error"] == true {
t.Errorf("explicit --continue-on-error=false must win over envelope; got continue_on_error=%#v", input["continue_on_error"])
}
})
t.Run("envelope applies when flag absent", func(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
"--url", testURL,
"--operations", envelope,
"--yes",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update")
if input["continue_on_error"] != true {
t.Errorf("envelope continue_on_error:true should apply when --continue-on-error absent; got %#v", input["continue_on_error"])
}
})
}
// TestExecute_WorkbookCreate covers the create POST + first-sheet lookup +
// set_cell_range follow-up. Stubs all three endpoints.
func TestExecute_WorkbookCreate(t *testing.T) {
@@ -316,6 +391,81 @@ func TestExecute_WorkbookCreate(t *testing.T) {
}
}
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
// the initial fill (no structure/fill calls fire) and finish with the
// spreadsheet created but no initial_fill — never panic on a nil fill map.
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, flag, val string }{
{"empty values", "--values", "[]"},
{"empty headers", "--headers", "[]"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
create := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtNEW", "title": "X"},
},
},
}
// Only the create stub is provided: an empty array must skip the fill
// entirely, so no structure/fill call fires (and no nil-map panic).
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", tc.flag, tc.val}, create)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if data["initial_fill"] != nil {
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
}
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
}
})
}
}
// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-success
// contract: when the spreadsheet is created but the follow-up fill can't resolve
// its first sheet, the error must be structured and retain spreadsheet_token so
// the caller can recover instead of orphaning the new workbook.
func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
t.Parallel()
create := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtNEW", "title": "X"},
},
},
}
// Structure comes back with no sheets, so lookupFirstSheetID fails AFTER the
// spreadsheet already exists — exercising the partial-success path.
structure := toolOutputStub("shtNEW", "read", `{"sheets":[]}`)
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", "--values", `[["a"]]`}, create, structure)
if err == nil {
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("error type = %T, want *output.ExitError (structured)", err)
}
if exitErr.Detail == nil {
t.Fatal("ExitError.Detail is nil; want structured detail carrying the token")
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["spreadsheet_token"] != "shtNEW" {
t.Errorf("detail.spreadsheet_token = %v, want shtNEW (must survive the fill failure)", detail["spreadsheet_token"])
}
}
// TestExecute_DimMove covers the native v3 move_dimension call. CLI's
// --source-range "1:3" (1-based inclusive) is parsed into v3's
// source.{start_index=0,end_index=2} (0-based inclusive); --target "11" is

View File

@@ -117,7 +117,7 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
// Reformat for readability — schema files store compact JSON.
var pretty interface{}
if err := json.Unmarshal(schema, &pretty); err != nil {
return schema, nil
return nil, err
}
return json.MarshalIndent(pretty, "", " ")
}

View File

@@ -5,8 +5,11 @@ package sheets
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
)
// TestFlagSchemas_EmbedParses asserts the synced flag-schemas.json
@@ -171,6 +174,32 @@ func TestPrintSchema_SystemFlagAbsentForReadOnlyShortcut(t *testing.T) {
}
}
// TestPrintSchema_UnknownFlagNameIsStructured pins issue #6: an unregistered
// --flag-name passed to --print-schema must surface as a structured
// *output.ExitError (type print_schema_error), not a bare error string, so the
// agent-facing introspection path stays machine-parseable.
func TestPrintSchema_UnknownFlagNameIsStructured(t *testing.T) {
t.Parallel()
// PrintFlagSchema is wired during registration (shortcuts.go), not on the
// literal, so replicate that here to make Mount inject the --print-schema /
// --flag-name system flags.
sc := CellsSet
sc.PrintFlagSchema = printFlagSchemaFor(sc.Command)
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--print-schema", "--flag-name", "nonexistent",
})
if err == nil {
t.Fatal("expected an error for --print-schema with an unregistered flag name")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want a structured *output.ExitError", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "print_schema_error" {
t.Errorf("error detail = %+v, want type print_schema_error", exitErr.Detail)
}
}
func keysOf(m map[string]interface{}) []string {
out := make([]string, 0, len(m))
for k := range m {

View File

@@ -75,8 +75,8 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
if command == "" {
return nil
}
idx, err := loadFlagSchemas()
if err != nil || idx == nil {
idx, _ := loadFlagSchemas()
if idx == nil {
return nil
}
entry, ok := idx.Flags[command]
@@ -88,9 +88,7 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
return nil
}
var schema schemaProperty
if err := json.Unmarshal(raw, &schema); err != nil {
return nil
}
json.Unmarshal(raw, &schema)
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
return common.FlagErrorf("--%s: %s", name, vErr.Error())
}
@@ -113,8 +111,8 @@ func validateInputAgainstSchema(fv flagView, input map[string]interface{}) error
if command == "" {
return nil
}
idx, err := loadFlagSchemas()
if err != nil || idx == nil {
idx, _ := loadFlagSchemas()
if idx == nil {
return nil
}
entry, ok := idx.Flags[command]

View File

@@ -226,7 +226,6 @@ func TestValidateAgainstSchema(t *testing.T) {
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := parseSchema(t, tc.schema)

View File

@@ -21,7 +21,6 @@ import (
type flagView interface {
Str(name string) string
Int(name string) int
Int64(name string) int64
Float64(name string) float64
Bool(name string) bool
StrArray(name string) []string
@@ -90,10 +89,6 @@ func typedDefault(df flagDef) interface{} {
var n int
fmt.Sscanf(df.Default, "%d", &n)
return n
case "int64":
var n int64
fmt.Sscanf(df.Default, "%d", &n)
return n
case "float64":
var f float64
fmt.Sscanf(df.Default, "%g", &f)
@@ -175,22 +170,6 @@ func (m mapFlagView) Int(name string) int {
return 0
}
func (m mapFlagView) Int64(name string) int64 {
v, ok := m.lookup(name)
if !ok {
return 0
}
switch t := v.(type) {
case float64:
return int64(t)
case int:
return int64(t)
case int64:
return t
}
return 0
}
func (m mapFlagView) Float64(name string) float64 {
v, ok := m.lookup(name)
if !ok {
@@ -254,3 +233,76 @@ func (m mapFlagView) Changed(name string) bool {
_, ok := m.lookupRaw(name)
return ok
}
// validateRawTypes rejects sub-op input fields whose JSON type contradicts the
// flag's declared type in flag-defs. +batch-update skips parse-time schema
// validation for `operations`, and Int/Float64/Bool silently fall back to
// the zero value on a type mismatch — so without this guard a wrong-typed scalar
// (e.g. "index":"abc" or "multiple":"true") would land as 0 / false instead of
// erroring, writing to the wrong place. Only numeric and boolean flags are
// checked; string and composite (array/object) flags stay permissive because
// Str() intentionally coerces them and the translator/schema validates shape.
//
// Returns a bare error; the +batch-update translator wraps it with the
// operations[i] (<shortcut>) context.
func (m mapFlagView) validateRawTypes() error {
if len(m.raw) == 0 {
return nil
}
defs, err := loadFlagDefs()
if err != nil {
return nil //nolint:nilerr // fail-open: if flag-defs can't load, skip type validation rather than block the batch
}
spec, ok := defs[m.command]
if !ok {
return nil
}
declaredType := make(map[string]string, len(spec.Flags))
for _, df := range spec.Flags {
declaredType[df.Name] = df.Type
}
for rawKey, val := range m.raw {
name := rawKey
typ, ok := declaredType[name]
if !ok {
// flag-defs use hyphen names; tolerate the underscore form users send.
name = strings.ReplaceAll(rawKey, "_", "-")
typ, ok = declaredType[name]
}
if !ok {
continue // unknown key — leave it for the translator / schema layer
}
switch typ {
case "int", "float64":
if _, isNum := val.(float64); !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
}
case "bool":
if _, isBool := val.(bool); !isBool {
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val))
}
}
}
return nil
}
// jsonTypeName names the JSON kind of a value decoded by encoding/json, for
// type-mismatch error messages.
func jsonTypeName(v interface{}) string {
switch v.(type) {
case nil:
return "null"
case bool:
return "boolean"
case float64:
return "number"
case string:
return "string"
case []interface{}:
return "array"
case map[string]interface{}:
return "object"
default:
return fmt.Sprintf("%T", v)
}
}

View File

@@ -104,11 +104,16 @@ func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]
"excel_id": token,
"operations": translated,
}
if runtime.Bool("continue-on-error") {
input["continue_on_error"] = true
if runtime.Changed("continue-on-error") {
// An explicit --continue-on-error always wins over the envelope, so
// --continue-on-error=false keeps the strict-transaction default even
// when the --operations envelope carries continue_on_error:true.
if runtime.Bool("continue-on-error") {
input["continue_on_error"] = true
}
} else if envelope, _ := parseJSONFlag(runtime, "operations"); envelope != nil {
// Honor an inline override when --operations is an envelope object
// rather than a bare operations array.
// No explicit flag: honor an inline override when --operations is an
// envelope object rather than a bare operations array.
if m, ok := envelope.(map[string]interface{}); ok {
if v, ok := m["continue_on_error"].(bool); ok && v {
input["continue_on_error"] = true
@@ -472,6 +477,16 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
if !strings.Contains(s, "!") {
return nil, common.FlagErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s)
}
// Validate the sheet!range shape up front so malformed entries like
// "!A1" (no sheet), "Sheet1!" (no range) or "Sheet1!bad" (bad ref) fail
// here at Validate instead of slipping through to DryRun/Execute.
_, sub, err := splitSheetPrefixedRange(s)
if err != nil {
return nil, common.FlagErrorf("--ranges[%d]: %v", i, err)
}
if _, _, err := rangeDimensions(sub); err != nil {
return nil, common.FlagErrorf("--ranges[%d] (%q): %v", i, s, err)
}
out = append(out, s)
}
return out, nil

View File

@@ -302,6 +302,39 @@ func TestBatchUpdate_ValidationGuards(t *testing.T) {
}
}
// TestValidateDropdownRanges_RejectsMalformedRange locks the up-front sheet!range
// validation: entries that merely contain "!" but are otherwise malformed (empty
// sheet, empty range, or an unparseable A1 ref) must fail at Validate rather than
// slip through to DryRun/Execute. Covers +dropdown-update / +dropdown-delete,
// which fan out over --ranges.
func TestValidateDropdownRanges_RejectsMalformedRange(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ranges string
want string
}{
{"no sheet prefix at all", `["A1:A5"]`, "must include a sheet prefix"},
{"empty sheet name", `["!A1:A5"]`, "must use sheet!range form"},
{"empty range after prefix", `["Sheet1!"]`, "must use sheet!range form"},
{"unparseable ref", `["Sheet1!bad"]`, "invalid cell ref"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", tc.ranges,
"--options", `["a"]`,
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), tc.want) {
t.Errorf("ranges=%s: expected error containing %q; got=%s|%s|%v", tc.ranges, tc.want, stdout, stderr, err)
}
})
}
}
// TestBatchUpdate_TranslatorRejects covers per-op shape errors caught by
// translateBatchOp: unknown shortcut, missing shortcut, banned (read /
// fan-out / legacy v2) shortcuts, hand-filled reserved keys, etc.
@@ -384,7 +417,6 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{

View File

@@ -434,7 +434,6 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
@@ -579,7 +578,6 @@ func TestObjectCreate_RequiresSheetSelector(t *testing.T) {
{"filter-view", FilterViewCreate, []string{"--url", testURL, "--properties", `{}`, "--range", "A1:F10"}},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
@@ -659,7 +657,6 @@ func TestObjectDelete_AllHighRisk(t *testing.T) {
{"float-image", FloatImageDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--float-image-id", "x"}},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)

View File

@@ -101,7 +101,6 @@ func TestObjectListShortcuts_DryRun(t *testing.T) {
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)

View File

@@ -256,7 +256,6 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
@@ -285,7 +284,6 @@ func TestRangeSort_RejectsMalformedKeys(t *testing.T) {
{"non-object item", `["B"]`, `[0]: expected type "object"`},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, RangeSort, []string{
@@ -348,7 +346,6 @@ func TestResize_TypeAndSizeGuards(t *testing.T) {
},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))

View File

@@ -79,7 +79,6 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
@@ -122,7 +121,6 @@ func TestReadData_RequiresRange(t *testing.T) {
{"+dropdown-get", DropdownGet},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, c.sc, []string{

View File

@@ -67,7 +67,6 @@ func TestSearchReplaceShortcuts_DryRun(t *testing.T) {
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)

View File

@@ -160,7 +160,6 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
@@ -197,7 +196,6 @@ func TestDimRange_Validation(t *testing.T) {
},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, DimHide, tt.args)
@@ -306,7 +304,6 @@ func TestParseA1Range(t *testing.T) {
{"0", "", 0, 0, true}, // rows are 1-based
}
for _, c := range cases {
c := c
t.Run(c.in, func(t *testing.T) {
t.Parallel()
dim, start, end, err := parseA1Range(c.in)

View File

@@ -598,8 +598,7 @@ var WorkbookCreate = common.Shortcut{
POST("/open-apis/sheets/v3/spreadsheets").
Desc("create spreadsheet").
Body(body)
if runtime.Str("headers") != "" || runtime.Str("values") != "" {
fill, _ := buildInitialFillInput(runtime)
if fill, _ := buildInitialFillInput(runtime); fill != nil {
fill["excel_id"] = "<new-token>"
fill["sheet_id"] = "<first-sheet-id>" // resolved from the workbook at execute time
wireBody, _ := buildToolBody("set_cell_range", fill)
@@ -629,24 +628,27 @@ var WorkbookCreate = common.Shortcut{
result := map[string]interface{}{"spreadsheet": ss}
if runtime.Str("headers") != "" || runtime.Str("values") != "" {
fill, err := buildInitialFillInput(runtime)
if err != nil {
return err
}
// --headers / --values are optional. buildInitialFillInput returns
// (nil, nil) when both are absent or empty, in which case we skip the
// fill entirely rather than dereferencing a nil map.
fill, err := buildInitialFillInput(runtime)
if err != nil {
return err
}
if fill != nil {
fill["excel_id"] = token
// set_cell_range needs a concrete sheet selector; the create
// response doesn't echo the default sheet's id, so read it back.
firstSheetID, err := lookupFirstSheetID(ctx, runtime, token)
if err != nil {
return fmt.Errorf("spreadsheet %s created but resolving its first sheet for initial fill failed: %w", token, err)
return workbookCreatedButFillFailed(token, ss,
fmt.Sprintf("resolving its first sheet for initial fill failed: %v", err))
}
fill["sheet_id"] = firstSheetID
fillOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", fill)
if err != nil {
// Spreadsheet exists; surface the fill failure but keep the new
// token in the envelope so the caller can recover or retry.
return fmt.Errorf("spreadsheet %s created but initial fill failed: %w", token, err)
return workbookCreatedButFillFailed(token, ss,
fmt.Sprintf("initial fill failed: %v", err))
}
result["initial_fill"] = fillOut
}
@@ -658,6 +660,26 @@ var WorkbookCreate = common.Shortcut{
},
}
// workbookCreatedButFillFailed builds a structured partial-success error for the
// window where the spreadsheet POST succeeded but the follow-up initial fill did
// not. The new spreadsheet_token is surfaced in the error detail so callers can
// retry the fill (+cells-set / +csv-put) or delete the orphan, instead of only
// finding the token interpolated into a bare error string.
func workbookCreatedButFillFailed(token string, spreadsheet interface{}, reason string) error {
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_success",
Message: fmt.Sprintf("spreadsheet %s created but %s", token, reason),
Hint: "the spreadsheet exists; retry the fill with the returned spreadsheet_token, or delete it",
Detail: map[string]interface{}{
"spreadsheet_token": token,
"spreadsheet": spreadsheet,
},
},
}
}
// buildInitialFillInput zips --headers + --values into a single set_cell_range
// payload writing to the first sheet starting at A1.
func buildInitialFillInput(runtime *common.RuntimeContext) (map[string]interface{}, error) {
@@ -692,6 +714,13 @@ func buildInitialFillInput(runtime *common.RuntimeContext) (map[string]interface
maxCols = len(r)
}
}
if maxCols == 0 {
// --headers '[]' / --values '[]' parse to rows that carry no cells.
// There is nothing to write and a 0-width range ("A1:1") would be
// illegal, so treat it as "no initial fill" — same contract as the
// len(rows)==0 case above — and let the caller skip the write.
return nil, nil
}
// Normalize rows to the same length so cells matrix is rectangular.
for i := range rows {
for len(rows[i]) < maxCols {
@@ -936,7 +965,13 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
continue
}
id, _ := sm["sheet_id"].(string)
// get_workbook_structure surfaces the sub-sheet's display name as
// "title"; older/alt payloads use "sheet_name". Match either so a
// --sheet-name lookup resolves regardless of the field name.
name, _ := sm["sheet_name"].(string)
if name == "" {
name, _ = sm["title"].(string)
}
if (sheetID != "" && id == sheetID) || (sheetName != "" && name == sheetName) {
idx, ok := util.ToFloat64(sm["index"])
if !ok {

View File

@@ -142,7 +142,6 @@ func TestWorkbookShortcuts_DryRun(t *testing.T) {
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
@@ -184,7 +183,6 @@ func TestSheetMove_DryRunResolvePlaceholders(t *testing.T) {
},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, SheetMove, tt.args)
@@ -260,7 +258,6 @@ func TestWorkbook_Validation(t *testing.T) {
},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
@@ -332,7 +329,6 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
{"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, append(tt.args, "--dry-run"))

View File

@@ -310,10 +310,18 @@ func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string
// defaults to "A1" and is therefore never empty. A range like "A1:H17"
// collapses to its top-left cell; +csv-put pastes from the anchor and
// auto-expands, so the range's lower-right bound is irrelevant.
//
// Standalone enforces "one of --start-cell / --range" via cobra's
// MarkFlagsOneRequired (see PostMount). A +batch-update sub-op never runs
// cobra, so without an explicit check the default "A1" silently wins and the
// paste lands at A1 instead of failing like the standalone command. Mirror
// the standalone contract: when --start-cell is absent, --range is mandatory.
if !runtime.Changed("start-cell") {
if rng := strings.TrimSpace(runtime.Str("range")); rng != "" {
anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0])
rng := strings.TrimSpace(runtime.Str("range"))
if rng == "" {
return nil, common.FlagErrorf("--start-cell or --range is required")
}
anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0])
}
if anchor == "" {
return nil, common.FlagErrorf("--start-cell is required")

View File

@@ -106,7 +106,6 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) {
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
@@ -217,7 +216,6 @@ func TestDropdownSet_HighlightTriState(t *testing.T) {
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownSet, tc.args)

View File

@@ -1,6 +1,6 @@
---
name: lark-sheets
version: 2.0.0-draft
version: 2.0.0
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。仅针对飞书在线电子表格不适用于本地 Excel 文件。"
metadata:
requires:
@@ -107,7 +107,7 @@ metadata:
- ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id或上下文之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试。
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id``--sheet-name`,否则照样报上面的错。
- ⚠️ **A1 reference 含 `!`**`--source` / `--range` / `--ranges`**shell session 起手先 `set +H`** 关 bash history expansion否则 `"Sheet1!A1"` 会被拦成 `event not found`;含特殊字符(`-` / 空格 / 非 ASCII的 sheet 名还要内部 single-quote 包,如 `--source "'Sales-2025'!A1:D100"`
- **例外**:徽章标为 `_公共URL/token无 sheet 定位…_` 的 shortcut`+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-get|update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create``--target-sheet-id` / `--target-sheet-name`XOR可都不传落点细节见 `lark-sheets-pivot-table`)。
- **例外**:徽章标为 `_公共URL/token无 sheet 定位…_` 的 shortcut`+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create``--target-sheet-id` / `--target-sheet-name`XOR可都不传落点细节见 `lark-sheets-pivot-table`)。
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |