mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
14 Commits
v1.0.47
...
feat/short
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cd34b1f70 | ||
|
|
9a53a1f2b8 | ||
|
|
eb6f5aa60a | ||
|
|
c4eb18cecc | ||
|
|
a510e07dfc | ||
|
|
f83c79825d | ||
|
|
9adc79d0c1 | ||
|
|
6b4bc0cc64 | ||
|
|
b5cd535285 | ||
|
|
098659cc18 | ||
|
|
8d8acb8252 | ||
|
|
ccf654d3f0 | ||
|
|
ad4368ed2a | ||
|
|
a07239b923 |
@@ -57,14 +57,6 @@ linters:
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
# internal/gen build-time generators (standalone `package main` run via
|
||||
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
|
||||
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
|
||||
# impossible here: a structured error return needs os.Exit (also banned),
|
||||
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
|
||||
- path: shortcuts/.*/internal/gen/
|
||||
linters:
|
||||
- forbidigo
|
||||
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
||||
# for the client / credential layer.
|
||||
- path-except: shortcuts/
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -2,26 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.47] - 2026-06-03
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
|
||||
- **base**: Add base block shortcuts (#1044)
|
||||
- **im**: Complete card message format (#1198)
|
||||
- **im**: Improve markdown guidance for messages (#1237)
|
||||
- **vc**: Forward invite call-id on meeting join (#1243)
|
||||
- **drive**: Emit typed error envelopes across the drive domain (#1205)
|
||||
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
|
||||
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
|
||||
- **wiki**: Support `appid` member type (#1235)
|
||||
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
|
||||
- **config**: Validate credentials after `config init` (#1151)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **skills**: Recover empty fallback for skills to update (#1233)
|
||||
|
||||
## [v1.0.46] - 2026-06-02
|
||||
|
||||
### Features
|
||||
@@ -1009,7 +989,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
||||
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
||||
|
||||
@@ -90,7 +90,6 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
||||
|
||||
@@ -718,23 +718,3 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("--json should be accepted without error, got: %v", err)
|
||||
}
|
||||
if gotOpts.Method != "GET" {
|
||||
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,10 +527,10 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
|
||||
continue
|
||||
}
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
if domainSet[sc.GetService()] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
@@ -557,11 +557,11 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
|
||||
continue
|
||||
}
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
if !registry.HasAuthDomain(sc.GetService()) {
|
||||
domains[sc.GetService()] = true
|
||||
}
|
||||
}
|
||||
return domains
|
||||
@@ -580,8 +580,8 @@ func sortedKnownDomains(brand core.LarkBrand) []string {
|
||||
|
||||
// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot").
|
||||
// Empty AuthTypes defaults to ["user"].
|
||||
func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
func shortcutSupportsIdentity(sc common.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{"user"}
|
||||
}
|
||||
|
||||
@@ -64,12 +64,13 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
shortcutOnlySet[n] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !seen[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
|
||||
dm := buildDomainMeta(sc.Service, lang)
|
||||
svc := sc.GetService()
|
||||
if !seen[svc] {
|
||||
if shortcutOnlySet[svc] && !registry.HasAuthDomain(svc) {
|
||||
dm := buildDomainMeta(svc, lang)
|
||||
domains = append(domains, dm)
|
||||
}
|
||||
seen[sc.Service] = true
|
||||
seen[svc] = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestNormalizeScopeInput(t *testing.T) {
|
||||
|
||||
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
|
||||
// Empty AuthTypes defaults to ["user"]
|
||||
sc := common.Shortcut{AuthTypes: nil}
|
||||
sc := &common.Shortcut{AuthTypes: nil}
|
||||
if !shortcutSupportsIdentity(sc, "user") {
|
||||
t.Error("expected default to support 'user'")
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
|
||||
sc := common.Shortcut{AuthTypes: []string{"user", "bot"}}
|
||||
sc := &common.Shortcut{AuthTypes: []string{"user", "bot"}}
|
||||
if !shortcutSupportsIdentity(sc, "user") {
|
||||
t.Error("expected to support 'user'")
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShortcutSupportsIdentity_BotOnly(t *testing.T) {
|
||||
sc := common.Shortcut{AuthTypes: []string{"bot"}}
|
||||
sc := &common.Shortcut{AuthTypes: []string{"bot"}}
|
||||
if shortcutSupportsIdentity(sc, "user") {
|
||||
t.Error("expected bot-only to NOT support 'user'")
|
||||
}
|
||||
|
||||
@@ -117,13 +117,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
|
||||
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
|
||||
// inherited by every subcommand, turning unknown-flag errors into a
|
||||
// structured "did you mean" envelope.
|
||||
rootCmd.SilenceUsage = true
|
||||
rootCmd.SetFlagErrorFunc(flagDidYouMean)
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
|
||||
@@ -47,8 +47,8 @@ func diagAllKnownDomains() []string {
|
||||
seen[p] = true
|
||||
}
|
||||
for _, s := range shortcuts.AllShortcuts() {
|
||||
if s.Service != "" {
|
||||
seen[s.Service] = true
|
||||
if s.GetService() != "" {
|
||||
seen[s.GetService()] = true
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
@@ -94,17 +94,17 @@ func diagBuild(domains []string) diagOutput {
|
||||
}
|
||||
|
||||
for _, sc := range allSC {
|
||||
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
|
||||
if sc.GetService() != domain || !diagShortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
|
||||
k := methodKey{domain, "shortcut", sc.Command, scope}
|
||||
k := methodKey{domain, "shortcut", sc.GetCommand(), scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
} else {
|
||||
merged[k] = &diagMethodEntry{
|
||||
Domain: domain, Type: "shortcut",
|
||||
Method: sc.Command,
|
||||
Method: sc.GetCommand(),
|
||||
Scope: scope, Identity: []string{identity},
|
||||
}
|
||||
}
|
||||
@@ -148,11 +148,12 @@ func diagBuild(domains []string) diagOutput {
|
||||
return diagOutput{Methods: methods, Scopes: scopes}
|
||||
}
|
||||
|
||||
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
|
||||
if len(sc.AuthTypes) == 0 {
|
||||
func diagShortcutSupportsIdentity(sc shortcutTypes.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
if len(authTypes) == 0 {
|
||||
return identity == "user"
|
||||
}
|
||||
for _, a := range sc.AuthTypes {
|
||||
for _, a := range authTypes {
|
||||
if a == identity {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
|
||||
service := cmd.Parent().Name()
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
if sc.GetService() != service || sc.GetCommand() != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
scopes := sc.DeclaredScopesForIdentity(identity)
|
||||
@@ -154,8 +154,8 @@ func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []s
|
||||
|
||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||
// identity, applying the default user-only behavior when AuthTypes is empty.
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{string(core.AsUser)}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
const maxSuggestions = 3
|
||||
@@ -29,7 +28,7 @@ func suggestEventKeys(input string) []string {
|
||||
hits = append(hits, match{def.Key, 0})
|
||||
continue
|
||||
}
|
||||
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
|
||||
if d := levenshtein(input, def.Key); d <= threshold {
|
||||
hits = append(hits, match{def.Key, d})
|
||||
}
|
||||
}
|
||||
@@ -70,3 +69,34 @@ func unknownEventKeyErr(key string) error {
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
}
|
||||
|
||||
// levenshtein computes classic edit distance (two-row DP).
|
||||
func levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,27 @@ import (
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"a", "", 1},
|
||||
{"", "abc", 3},
|
||||
{"kitten", "kitten", 0},
|
||||
{"kitten", "sitten", 1},
|
||||
{"kitten", "sitting", 3},
|
||||
{"飞书", "飞书", 0},
|
||||
{"飞书", "飞s", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := levenshtein(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestEventKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUnknownFlagName(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"unknown flag: --query", "query", true},
|
||||
{"unknown flag: --with-styles", "with-styles", true},
|
||||
{"unknown shorthand flag: 'z' in -z", "", false},
|
||||
{"flag needs an argument: --find", "", false},
|
||||
{`invalid argument "x" for "--count"`, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, ok := unknownFlagName(errors.New(c.in))
|
||||
if name != c.name || ok != c.ok {
|
||||
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
c.Flags().String("find", "", "")
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--range") {
|
||||
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]any)
|
||||
valid, _ := detail["valid_flags"].([]string)
|
||||
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
|
||||
t.Errorf("valid_flags should list find & range, got %v", valid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "flag_error" {
|
||||
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
)
|
||||
|
||||
// composePendingNotice must surface a deprecated-command alias under the
|
||||
// "deprecated_command" key, with the migration target and a skill-update hint,
|
||||
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
|
||||
// without ever reading --help.
|
||||
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+read",
|
||||
Replacement: "+cells-get",
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
|
||||
got := composePendingNotice()
|
||||
if got == nil {
|
||||
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
|
||||
}
|
||||
entry, ok := got["deprecated_command"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing deprecated_command key: %#v", got)
|
||||
}
|
||||
if entry["command"] != "+read" {
|
||||
t.Errorf("command = %v, want +read", entry["command"])
|
||||
}
|
||||
if entry["replacement"] != "+cells-get" {
|
||||
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
|
||||
}
|
||||
if entry["skill"] != "lark-sheets" {
|
||||
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
|
||||
}
|
||||
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
|
||||
t.Errorf("message missing skill-update hint: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing pending, the provider returns nil so no "_notice" field is
|
||||
// emitted on a clean run.
|
||||
func TestComposePendingNoticeEmpty(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
if got := composePendingNotice(); got != nil {
|
||||
// update/skills pending are process-global; only assert the absence of
|
||||
// our own key to stay robust against unrelated pending state.
|
||||
if _, ok := got["deprecated_command"]; ok {
|
||||
t.Fatalf("deprecated_command present after clear: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
377
cmd/root.go
377
cmd/root.go
@@ -18,17 +18,14 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
@@ -72,15 +69,7 @@ COMMUNITY:
|
||||
More help: lark-cli <command> --help`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
||||
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
|
||||
// from here. It stays nil in unit tests that invoke a RunE directly with
|
||||
// explicit args — correct, since those don't exercise the whitelist path.
|
||||
var rawInvocationArgs []string
|
||||
|
||||
func Execute() int {
|
||||
rawInvocationArgs = os.Args[1:]
|
||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
@@ -144,49 +133,29 @@ func setupNotices() {
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = composePendingNotice
|
||||
}
|
||||
|
||||
// composePendingNotice merges all process-level pending notices (available
|
||||
// update, skills/binary drift, deprecated-command alias) into the map surfaced
|
||||
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
|
||||
// Extracted from Execute so the composition is unit-testable.
|
||||
func composePendingNotice() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if dep := deprecation.GetPending(); dep != nil {
|
||||
entry := map[string]interface{}{
|
||||
"command": dep.Command,
|
||||
"message": dep.Message(),
|
||||
"action": "lark-cli update",
|
||||
}
|
||||
if dep.Replacement != "" {
|
||||
entry["replacement"] = dep.Replacement
|
||||
}
|
||||
if dep.Skill != "" {
|
||||
entry["skill"] = dep.Skill
|
||||
}
|
||||
notice["deprecated_command"] = entry
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
|
||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||
@@ -291,19 +260,6 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return exitErr.Code
|
||||
}
|
||||
|
||||
// A backward-compat alias records its deprecation notice in PreRunE, which
|
||||
// runs before cobra's required-flag validation — but a missing required flag
|
||||
// fails before RunE and lands here, where the bare "Error:" line would drop
|
||||
// the notice. When a deprecation is pending, route through the structured
|
||||
// envelope so the migration hint still reaches the caller; all other errors
|
||||
// keep the existing plain output.
|
||||
if deprecation.GetPending() != nil {
|
||||
output.WriteErrorEnvelope(errOut, &output.ExitError{
|
||||
Code: 1,
|
||||
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
|
||||
}, string(f.ResolvedIdentity))
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(errOut, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
@@ -345,12 +301,6 @@ func asExitError(err error) *output.ExitError {
|
||||
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
||||
cmd.RunE = unknownSubcommandRunE
|
||||
// Route an unknown subcommand to unknownSubcommandRunE even when flags
|
||||
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
|
||||
// consumes no flags itself, so unknown flags belong to the (missing)
|
||||
// subcommand; whitelisting them here prevents cobra from erroring on the
|
||||
// flag first and printing usage instead of our structured suggestion.
|
||||
cmd.FParseErrWhitelist.UnknownFlags = true
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
@@ -370,89 +320,14 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
// they have moved to the typed surface.
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
|
||||
// like the global --profile, legitimately prints help. But a flag that
|
||||
// belongs to a (missing) subcommand is a user error: the guard's
|
||||
// FParseErrWhitelist swallows such flags and leaves args empty, so without
|
||||
// the checks below they would silently fall through to help + exit 0 —
|
||||
// letting an agent mistake a malformed call (`im --format json`,
|
||||
// `sheets --badflag`) for success. Recover the swallowed tokens from the
|
||||
// raw invocation and fail structured instead.
|
||||
flags := flagTokensInArgs(rawInvocationArgs)
|
||||
if len(flags) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
|
||||
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
// Keep the same detail keys as flagDidYouMean's unknown_flag
|
||||
// so a consumer keyed on Type can read a stable shape. The
|
||||
// subcommand isn't resolved here, so suggestions/valid_flags
|
||||
// have no meaningful universe to draw from — emit empty
|
||||
// rather than the group's own (misleading) flags. unknown is
|
||||
// the back-compat singular field; unknown_flags carries the
|
||||
// full list when more than one flag was supplied.
|
||||
"unknown": strings.Join(unknown, ", "),
|
||||
"unknown_flags": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": []string{},
|
||||
"valid_flags": []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
// The remaining flags are all defined somewhere in the tree. Those valid
|
||||
// on the group itself or inherited (e.g. the global --profile) do not
|
||||
// require a subcommand, so a bare group carrying only those still prints
|
||||
// help. Anything left belongs to a subcommand that was omitted
|
||||
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
|
||||
// real, the subcommand is what's missing.
|
||||
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
|
||||
if len(misplaced) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "missing_subcommand",
|
||||
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
|
||||
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
"command_path": cmd.CommandPath(),
|
||||
"flags": misplaced,
|
||||
"suggestions": []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
return cmd.Help()
|
||||
}
|
||||
unknown := args[0]
|
||||
available, deprecated := availableSubcommandNames(cmd)
|
||||
// Rank suggestions across both current and deprecated names so a mistyped
|
||||
// legacy command (e.g. +raed → +read) still resolves; the alias stays
|
||||
// runnable and self-flags via the _notice on execution.
|
||||
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
|
||||
available := availableSubcommandNames(cmd)
|
||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
|
||||
strings.Join(suggestions, ", "), cmd.CommandPath())
|
||||
}
|
||||
detail := map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
}
|
||||
// Only services with backward-compat aliases (currently sheets) carry a
|
||||
// deprecated bucket; omit the key elsewhere so every other service's
|
||||
// envelope is unchanged.
|
||||
if len(deprecated) > 0 {
|
||||
detail["deprecated"] = deprecated
|
||||
if len(available) > 0 {
|
||||
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
@@ -460,114 +335,17 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
Detail: map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"available": available,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
|
||||
// rawArgs, stopping at the "--" positional terminator. Whether a flag is
|
||||
// defined is not considered (see unknownFlagTokens for that). A pure group
|
||||
// with any flag token but no subcommand is a user error — a pure group
|
||||
// consumes no flags of its own, so the flag must belong to a subcommand — so
|
||||
// the caller fails structured instead of falling through to help.
|
||||
func flagTokensInArgs(rawArgs []string) []string {
|
||||
var toks []string
|
||||
for _, a := range rawArgs {
|
||||
if a == "--" {
|
||||
break // everything after -- is positional
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
continue
|
||||
}
|
||||
toks = append(toks, a)
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
|
||||
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
|
||||
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
|
||||
// the suggestion path; the side effect is that flags before a subcommand are
|
||||
// swallowed. This recovers the genuinely-unknown ones so the caller can name
|
||||
// them in a "did you mean" envelope.
|
||||
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var unknown []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name != "" && !flagDefinedInTree(cmd, name) {
|
||||
unknown = append(unknown, a)
|
||||
}
|
||||
}
|
||||
return unknown
|
||||
}
|
||||
|
||||
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
|
||||
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
|
||||
// group and therefore not requiring a subcommand.
|
||||
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
lookup := func(fs *pflag.FlagSet) bool {
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
|
||||
}
|
||||
|
||||
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
|
||||
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
|
||||
// omitting the subcommand they belong to (`im --format json`). Global flags
|
||||
// valid on the bare group (e.g. --profile) are excluded so
|
||||
// `lark-cli --profile p im` still prints help rather than erroring.
|
||||
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var misplaced []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name == "" || flagKnownOnGroup(cmd, name) {
|
||||
continue
|
||||
}
|
||||
if flagDefinedInTree(cmd, name) {
|
||||
misplaced = append(misplaced, a)
|
||||
}
|
||||
}
|
||||
return misplaced
|
||||
}
|
||||
|
||||
// flagDefinedInTree reports whether name is defined on cmd, its inherited
|
||||
// (persistent) flags, or any direct subcommand. The subcommand case covers a
|
||||
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
|
||||
// --format is injected on every leaf shortcut, not on the group — so only a
|
||||
// genuinely unknown flag like `sheets --badflag` is reported.
|
||||
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
known := func(c *cobra.Command, inherited bool) bool {
|
||||
fs := c.Flags()
|
||||
if inherited {
|
||||
fs = c.InheritedFlags()
|
||||
}
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
if known(cmd, false) || known(cmd, true) {
|
||||
return true
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
if known(c, false) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// availableSubcommandNames returns the invokable subcommand names of cmd, split
|
||||
// into current commands and backward-compatibility aliases (those tagged into
|
||||
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
|
||||
// sorted; hidden commands plus help/completion are omitted.
|
||||
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
subs := make([]string, 0, len(cmd.Commands()))
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || !c.IsAvailableCommand() {
|
||||
continue
|
||||
@@ -576,95 +354,10 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
|
||||
if name == "help" || name == "completion" {
|
||||
continue
|
||||
}
|
||||
if cmdutil.IsDeprecatedCommand(c) {
|
||||
deprecated = append(deprecated, name)
|
||||
} else {
|
||||
available = append(available, name)
|
||||
}
|
||||
subs = append(subs, name)
|
||||
}
|
||||
sort.Strings(available)
|
||||
sort.Strings(deprecated)
|
||||
return available, deprecated
|
||||
}
|
||||
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
|
||||
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
|
||||
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
|
||||
// --find, where edit distance alone finds nothing). Other flag errors stay
|
||||
// structured but generic.
|
||||
func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagName(ferr)
|
||||
if !isUnknown {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "flag_error",
|
||||
Message: ferr.Error(),
|
||||
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
|
||||
},
|
||||
}
|
||||
}
|
||||
valid := visibleFlagNames(c)
|
||||
suggestions := suggest.Closest(name, valid, 3)
|
||||
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
|
||||
if len(suggestions) > 0 {
|
||||
for i := range suggestions {
|
||||
suggestions[i] = "--" + suggestions[i]
|
||||
}
|
||||
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
|
||||
strings.Join(suggestions, ", "), c.CommandPath())
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": "--" + name,
|
||||
"command_path": c.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"valid_flags": valid,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
|
||||
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
|
||||
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
|
||||
// those structured but generic — hallucinated flags are essentially always long.
|
||||
//
|
||||
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
|
||||
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
|
||||
// silently fails and unknown flags degrade to a generic flag_error — re-verify
|
||||
// this prefix when bumping cobra.
|
||||
func unknownFlagName(err error) (string, bool) {
|
||||
const p = "unknown flag: --"
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, p)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := msg[i+len(p):]
|
||||
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
||||
rest = rest[:j]
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
|
||||
// the valid_flags detail).
|
||||
func visibleFlagNames(c *cobra.Command) []string {
|
||||
var names []string
|
||||
c.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !f.Hidden {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
})
|
||||
sort.Strings(names)
|
||||
return names
|
||||
sort.Strings(subs)
|
||||
return subs
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
@@ -269,54 +268,6 @@ func (f *failingWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
|
||||
// backward-compat alias that fails on a cobra-level required flag (which
|
||||
// short-circuits before RunE) still routes through the structured envelope,
|
||||
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
|
||||
// switches to WriteErrorEnvelope when a deprecation is pending — so the
|
||||
// migration notice is no longer dropped on the plain "Error:" line.
|
||||
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
||||
})
|
||||
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
|
||||
// nor an *output.ExitError, so it reaches the legacy fallback.
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
|
||||
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
|
||||
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
|
||||
// fix does not reshape every unrecognized cobra error.
|
||||
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
if !strings.HasPrefix(errOut.String(), "Error:") {
|
||||
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
||||
// stderr write fails mid-envelope, handleRootError still returns the typed
|
||||
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
||||
|
||||
@@ -180,7 +180,6 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
if risk == "high-risk-write" {
|
||||
|
||||
@@ -765,22 +765,3 @@ func TestDetectFileFields(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--json should be accepted without error, got: %v", err)
|
||||
}
|
||||
if captured == nil {
|
||||
t.Fatal("expected runF to be called")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -73,149 +72,6 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFlagTokens(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
|
||||
// the subcommand) is distinguished from a genuinely unknown one.
|
||||
for _, c := range drive.Commands() {
|
||||
if c.Name() == "+search" {
|
||||
c.Flags().String("query", "", "")
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
rawArgs []string
|
||||
want []string
|
||||
}{
|
||||
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
|
||||
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
|
||||
{"no flags at all", []string{"drive"}, nil},
|
||||
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
|
||||
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := unknownFlagTokens(drive, tc.rawArgs)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
|
||||
// --badflag, so RunE sees no args; the guard must recover it from
|
||||
// rawInvocationArgs and fail structured rather than print help + exit 0.
|
||||
rawInvocationArgs = []string{"drive", "--badflag"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
err := drive.RunE(drive, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
|
||||
}
|
||||
|
||||
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
|
||||
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
|
||||
}
|
||||
if detail["unknown"] != "--badflag" {
|
||||
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
|
||||
}
|
||||
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
|
||||
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
|
||||
}
|
||||
for _, key := range []string{"suggestions", "valid_flags"} {
|
||||
if _, present := detail[key]; !present {
|
||||
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
// --query is defined on the +search subcommand, so it is a *valid* flag that
|
||||
// was placed before the (omitted) subcommand. Unlike an unknown flag, this
|
||||
// must still fail structured (missing_subcommand) rather than fall through to
|
||||
// help + exit 0 — `drive --query x` is a malformed call, not a help request.
|
||||
for _, c := range drive.Commands() {
|
||||
if c.Name() == "+search" {
|
||||
c.Flags().String("query", "", "")
|
||||
}
|
||||
}
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
rawInvocationArgs = []string{"drive", "--query", "x"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
err := drive.RunE(drive, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
|
||||
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
|
||||
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
|
||||
}
|
||||
if detail["command_path"] != "lark-cli drive" {
|
||||
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
|
||||
}
|
||||
}
|
||||
|
||||
// A bare group carrying only a group-valid global flag (e.g. the inherited
|
||||
// --profile) is not missing a subcommand — those flags do not belong to a
|
||||
// subcommand — so it must print help, not fail with missing_subcommand.
|
||||
func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
rawInvocationArgs = []string{"--profile", "p", "drive"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
var buf bytes.Buffer
|
||||
drive.SetOut(&buf)
|
||||
drive.SetErr(&buf)
|
||||
if err := drive.RunE(drive, nil); err != nil {
|
||||
t.Fatalf("bare group with only a global flag should print help, got error: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "drive ops") {
|
||||
t.Errorf("expected help output, got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
@@ -257,11 +113,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
|
||||
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
|
||||
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
|
||||
// back to pointing at --help; the full machine-readable list lives in
|
||||
// detail.available below (which also excludes hidden commands).
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--help") {
|
||||
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
|
||||
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
|
||||
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "+secret") {
|
||||
t.Error("hidden commands must not appear in the hint")
|
||||
}
|
||||
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
@@ -308,7 +164,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
got, _ := availableSubcommandNames(root)
|
||||
got := availableSubcommandNames(root)
|
||||
want := []string{"alpha", "gamma"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
@@ -319,61 +175,3 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
root.AddCommand(
|
||||
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
available, deprecated := availableSubcommandNames(root)
|
||||
if len(available) != 1 || available[0] != "+new-cmd" {
|
||||
t.Errorf("available = %v, want [+new-cmd]", available)
|
||||
}
|
||||
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
|
||||
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
|
||||
}
|
||||
}
|
||||
|
||||
// unknownSubcommandRunE must split current vs deprecated subcommands into
|
||||
// separate detail buckets, while suggestions still rank across both so a
|
||||
// mistyped legacy alias resolves.
|
||||
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
|
||||
svc := &cobra.Command{Use: "sheets"}
|
||||
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
svc.AddCommand(
|
||||
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
err := unknownSubcommandRunE(svc, []string{"+reat"})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
|
||||
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
|
||||
t.Errorf("available = %v, want [+cells-get]", available)
|
||||
}
|
||||
deprecated, ok := detail["deprecated"].([]string)
|
||||
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
|
||||
t.Errorf("deprecated = %v, want [+read]", deprecated)
|
||||
}
|
||||
// suggestions rank across both buckets: "+reat" is closest to +read.
|
||||
suggestions, _ := detail["suggestions"].([]string)
|
||||
found := false
|
||||
for _, s := range suggestions {
|
||||
if s == "+read" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
14
errs/subtypes_shortcut.go
Normal file
14
errs/subtypes_shortcut.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// Subtypes raised by the typed shortcut protocol (shortcuts/common). Only
|
||||
// cross-field semantic failures need their own subtype here; per-field
|
||||
// failures (required missing / enum invalid / typed-primitive format) reuse
|
||||
// SubtypeInvalidArgument.
|
||||
const (
|
||||
SubtypeShortcutOneOfMissing Subtype = "shortcut_oneof_missing"
|
||||
SubtypeShortcutOneOfMultiple Subtype = "shortcut_oneof_multiple"
|
||||
SubtypeShortcutGroupIncomplete Subtype = "shortcut_group_incomplete"
|
||||
)
|
||||
25
errs/subtypes_shortcut_test.go
Normal file
25
errs/subtypes_shortcut_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShortcutSubtypes_Values(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got Subtype
|
||||
want string
|
||||
}{
|
||||
{"OneOfMissing", SubtypeShortcutOneOfMissing, "shortcut_oneof_missing"},
|
||||
{"OneOfMultiple", SubtypeShortcutOneOfMultiple, "shortcut_oneof_multiple"},
|
||||
{"GroupIncomplete", SubtypeShortcutGroupIncomplete, "shortcut_group_incomplete"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.got) != tt.want {
|
||||
t.Errorf("got %q, want %q", string(tt.got), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -14,7 +14,7 @@ require (
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
|
||||
@@ -5,7 +5,6 @@ package cmdpolicy
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
// suggestRisk returns the closest valid Risk literal by edit distance
|
||||
@@ -21,9 +20,9 @@ func suggestRisk(bad string) string {
|
||||
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
|
||||
}
|
||||
best := string(candidates[0])
|
||||
bestDist := suggest.Levenshtein(lowered, best)
|
||||
bestDist := levenshtein(lowered, best)
|
||||
for _, c := range candidates[1:] {
|
||||
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
|
||||
if d := levenshtein(lowered, string(c)); d < bestDist {
|
||||
bestDist, best = d, string(c)
|
||||
}
|
||||
}
|
||||
@@ -41,3 +40,47 @@ func toLower(s string) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// levenshtein computes the classic edit distance between two strings.
|
||||
// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set
|
||||
// makes raw performance irrelevant — clarity beats trickiness here.
|
||||
func levenshtein(a, b string) int {
|
||||
if len(a) == 0 {
|
||||
return len(b)
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return len(a)
|
||||
}
|
||||
prev := make([]int, len(b)+1)
|
||||
curr := make([]int, len(b)+1)
|
||||
for j := 0; j <= len(b); j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(a); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(b); j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min3(
|
||||
prev[j]+1, // deletion
|
||||
curr[j-1]+1, // insertion
|
||||
prev[j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(b)]
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
m := a
|
||||
if b < m {
|
||||
m = b
|
||||
}
|
||||
if c < m {
|
||||
m = c
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -29,3 +29,23 @@ func TestSuggestRisk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"", "abc", 3},
|
||||
{"abc", "", 3},
|
||||
{"abc", "abc", 0},
|
||||
{"wrtie", "write", 2},
|
||||
{"kitten", "sitting", 3},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := levenshtein(c.a, c.b)
|
||||
if got != c.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
|
||||
// command — one kept alive for users whose skill predates a refactor. Service
|
||||
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
|
||||
// rendering and unknown-subcommand suggestions read it to separate these
|
||||
// aliases from the current commands.
|
||||
const DeprecatedGroupID = "deprecated"
|
||||
|
||||
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
|
||||
func IsDeprecatedCommand(c *cobra.Command) bool {
|
||||
return c != nil && c.GroupID == DeprecatedGroupID
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package deprecation carries a process-level notice that the command currently
|
||||
// being executed is a backward-compatibility alias, kept alive for users whose
|
||||
// skill predates a refactor. The notice is surfaced in JSON output envelopes via
|
||||
// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck.
|
||||
//
|
||||
// A CLI process runs exactly one shortcut, so a single process-level slot is
|
||||
// sufficient: the command's Execute records the notice before producing output,
|
||||
// and the output layer reads it back when building the envelope.
|
||||
package deprecation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Notice describes a deprecated command alias and the current command that
|
||||
// replaces it. Replacement and Skill are optional.
|
||||
type Notice struct {
|
||||
Command string `json:"command"`
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
Skill string `json:"skill,omitempty"`
|
||||
}
|
||||
|
||||
// Message returns a single-line, AI-agent-parseable description of the alias
|
||||
// plus the canonical fix (update the skill). Mirrors the style of
|
||||
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
|
||||
func (n *Notice) Message() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(n.Command)
|
||||
b.WriteString(" is a pre-refactor compatibility alias")
|
||||
if n.Replacement != "" {
|
||||
b.WriteString("; use ")
|
||||
b.WriteString(n.Replacement)
|
||||
b.WriteString(" instead")
|
||||
}
|
||||
if n.Skill != "" {
|
||||
b.WriteString("; update your ")
|
||||
b.WriteString(n.Skill)
|
||||
b.WriteString(" skill, run: lark-cli update")
|
||||
} else {
|
||||
b.WriteString("; update your skill, run: lark-cli update")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// pending stores the latest deprecation notice for the current process.
|
||||
var pending atomic.Pointer[Notice]
|
||||
|
||||
// SetPending stores the notice for consumption by output decorators.
|
||||
// Pass nil to clear.
|
||||
func SetPending(n *Notice) { pending.Store(n) }
|
||||
|
||||
// GetPending returns the pending deprecation notice, or nil.
|
||||
func GetPending() *Notice { return pending.Load() }
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package deprecation
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNoticeMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
notice Notice
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "replacement and skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no replacement",
|
||||
notice: Notice{Command: "+read", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.notice.Message(); got != tt.want {
|
||||
t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetPending(t *testing.T) {
|
||||
t.Cleanup(func() { SetPending(nil) })
|
||||
|
||||
SetPending(nil)
|
||||
if got := GetPending(); got != nil {
|
||||
t.Fatalf("expected nil pending after clear, got %#v", got)
|
||||
}
|
||||
|
||||
n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"}
|
||||
SetPending(n)
|
||||
got := GetPending()
|
||||
if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" {
|
||||
t.Fatalf("GetPending() = %#v, want %#v", got, n)
|
||||
}
|
||||
|
||||
SetPending(nil)
|
||||
if GetPending() != nil {
|
||||
t.Fatal("expected nil after clearing")
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package suggest provides the shared "did you mean" primitives: a rune-aware
|
||||
// Levenshtein edit distance and a prefix-weighted Closest ranker. It is the
|
||||
// single home for these so cmd, cmd/event, and internal/cmdpolicy stop each
|
||||
// carrying their own copy.
|
||||
package suggest
|
||||
|
||||
import "sort"
|
||||
|
||||
// Levenshtein computes the classic edit distance between two strings. It is
|
||||
// rune-aware, so it is correct for multi-byte input.
|
||||
func Levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
|
||||
// Closest returns up to maxN of candidates that plausibly match typed, ranked
|
||||
// by shared-prefix length (desc) then edit distance (asc), keeping only
|
||||
// reasonably-close ones.
|
||||
//
|
||||
// Shared prefix is weighted first on purpose: hallucinated names are often
|
||||
// semantically close but lexically far (e.g. "+cells-find" vs "+cells-search",
|
||||
// "--with-styles" vs nothing close), where the common prefix is the strongest
|
||||
// signal of intent that raw edit distance misses.
|
||||
func Closest(typed string, candidates []string, maxN int) []string {
|
||||
type scored struct {
|
||||
name string
|
||||
prefix int
|
||||
dist int
|
||||
}
|
||||
limit := editLimit(typed)
|
||||
ranked := make([]scored, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
p := sharedPrefixLen(typed, c)
|
||||
d := Levenshtein(typed, c)
|
||||
// Keep only plausible matches: a meaningful shared prefix, or an edit
|
||||
// distance within budget. Drop everything else so the hint stays short.
|
||||
if p >= 3 || d <= limit {
|
||||
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
|
||||
}
|
||||
}
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
if ranked[i].prefix != ranked[j].prefix {
|
||||
return ranked[i].prefix > ranked[j].prefix
|
||||
}
|
||||
if ranked[i].dist != ranked[j].dist {
|
||||
return ranked[i].dist < ranked[j].dist
|
||||
}
|
||||
return ranked[i].name < ranked[j].name
|
||||
})
|
||||
if maxN <= 0 || maxN > len(ranked) {
|
||||
maxN = len(ranked)
|
||||
}
|
||||
out := make([]string, 0, maxN)
|
||||
for _, s := range ranked[:maxN] {
|
||||
out = append(out, s.name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// editLimit allows roughly one third of the typed length in edits (min 2), so
|
||||
// short names tolerate a couple of typos and longer ones proportionally more.
|
||||
func editLimit(s string) int {
|
||||
if l := len([]rune(s)) / 3; l > 2 {
|
||||
return l
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func sharedPrefixLen(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
n := 0
|
||||
for n < len(ra) && n < len(rb) && ra[n] == rb[n] {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package suggest
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClosest_HallucinatedSharesPrefix(t *testing.T) {
|
||||
cmds := []string{
|
||||
"+cells-get", "+cells-set", "+cells-search", "+cells-replace",
|
||||
"+cells-clear", "+cells-merge", "+csv-get", "+chart-create",
|
||||
"+pivot-create", "+sheet-info",
|
||||
}
|
||||
// "+cells-find" is semantically +cells-search but lexically far; the shared
|
||||
// "+cells-" prefix should still surface the right family (incl. +cells-search).
|
||||
got := Closest("+cells-find", cmds, 6)
|
||||
if len(got) == 0 || len(got) > 6 {
|
||||
t.Fatalf("expected 1..6 suggestions, got %v", got)
|
||||
}
|
||||
if !slices.Contains(got, "+cells-search") {
|
||||
t.Errorf("expected +cells-search among suggestions, got %v", got)
|
||||
}
|
||||
for _, s := range got {
|
||||
if len(s) < 7 || s[:7] != "+cells-" {
|
||||
t.Errorf("suggestion %q does not share the +cells- prefix", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosest_TypoRanksExactNeighborFirst(t *testing.T) {
|
||||
got := Closest("+cell-get", []string{"+cells-get", "+cells-set", "+csv-get", "+sheet-info"}, 3)
|
||||
if len(got) == 0 || got[0] != "+cells-get" {
|
||||
t.Errorf("expected +cells-get first for typo +cell-get, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosest_NoPlausibleMatch(t *testing.T) {
|
||||
if got := Closest("+zzzzzz", []string{"+cells-get", "+csv-get"}, 6); len(got) != 0 {
|
||||
t.Errorf("expected no suggestions for unrelated input, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "abc", 3},
|
||||
{"abc", "", 3},
|
||||
{"abc", "abc", 0},
|
||||
{"kitten", "sitting", 3},
|
||||
{"cell-get", "cells-get", 1},
|
||||
{"--query", "--find", 5},
|
||||
{"飞书", "飞书", 0}, // rune-aware: multi-byte equal
|
||||
{"飞书", "飞s", 1}, // one rune substitution, not byte count
|
||||
}
|
||||
for _, c := range cases {
|
||||
if d := Levenshtein(c.a, c.b); d != c.want {
|
||||
t.Errorf("Levenshtein(%q,%q) = %d, want %d", c.a, c.b, d, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedPrefixLen(t *testing.T) {
|
||||
if got := sharedPrefixLen("+cells-find", "+cells-search"); got != 7 {
|
||||
t.Errorf("sharedPrefixLen = %d, want 7", got)
|
||||
}
|
||||
if got := sharedPrefixLen("abc", "xyz"); got != 0 {
|
||||
t.Errorf("sharedPrefixLen = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// migratedCommonHelperPaths lists source-tree prefixes whose command validation
|
||||
// has migrated to typed errs.* envelopes. On these paths, calls to common's
|
||||
// legacy validation/save helpers are forbidden; callers must use the typed
|
||||
// common replacements or construct an errs.* typed error directly.
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"shortcuts/drive/",
|
||||
}
|
||||
|
||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
var legacyCommonHelperReplacements = map[string]string{
|
||||
"FlagErrorf": "common.ValidationErrorf",
|
||||
"MutuallyExclusive": "common.MutuallyExclusiveTyped",
|
||||
"AtLeastOne": "common.AtLeastOneTyped",
|
||||
"ExactlyOne": "common.ExactlyOneTyped",
|
||||
"ValidatePageSize": "common.ValidatePageSizeTyped",
|
||||
"ValidateChatID": "common.ValidateChatIDTyped",
|
||||
"ValidateUserID": "common.ValidateUserIDTyped",
|
||||
"ValidateSafePath": "common.ValidateSafePathTyped",
|
||||
"RejectDangerousChars": "common.RejectDangerousCharsTyped",
|
||||
"WrapInputStatError": "common.WrapInputStatErrorTyped",
|
||||
"WrapSaveErrorByCategory": "common.WrapSaveErrorTyped",
|
||||
"ResolveOpenIDs": "common.ResolveOpenIDsTyped",
|
||||
"HandleApiResult": "runtime.CallAPITyped",
|
||||
}
|
||||
|
||||
// CheckNoLegacyCommonHelperCall flags any reference to common's legacy helper
|
||||
// APIs on migrated paths — direct calls and function-value references alike,
|
||||
// so `f := common.FlagErrorf; f(...)` cannot slip past the guard. These
|
||||
// helpers return legacy output envelopes or bare errors, so migrated domains
|
||||
// should use their typed-aware replacements.
|
||||
func CheckNoLegacyCommonHelperCall(path, src string) []Violation {
|
||||
if !isMigratedCommonHelperPath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
localNames, dotImported := resolveCommonNames(file)
|
||||
var out []Violation
|
||||
report := func(pos token.Pos, name, replacement string) {
|
||||
out = append(out, Violation{
|
||||
Rule: "no_legacy_common_helper_call",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(pos).Line,
|
||||
Message: "common." + name + " returns a legacy error shape and is forbidden on migrated paths",
|
||||
Suggestion: "replace common." + name + " with " + replacement + " or a typed errs.* constructor",
|
||||
})
|
||||
}
|
||||
// Pass 1: qualified references (common.X / alias.X). Record every
|
||||
// selector field so the dot-import pass below never mistakes another
|
||||
// package's same-named field for a common helper.
|
||||
selFields := make(map[*ast.Ident]struct{})
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
sel, ok := n.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
selFields[sel.Sel] = struct{}{}
|
||||
x, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if _, bound := localNames[x.Name]; !bound {
|
||||
return true
|
||||
}
|
||||
if replacement, ok := legacyCommonHelperReplacements[sel.Sel.Name]; ok {
|
||||
report(sel.Pos(), sel.Sel.Name, replacement)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Pass 2: unqualified references under a dot import.
|
||||
if dotImported {
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
ident, ok := n.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if _, isField := selFields[ident]; isField {
|
||||
return true
|
||||
}
|
||||
if replacement, ok := legacyCommonHelperReplacements[ident.Name]; ok {
|
||||
report(ident.Pos(), ident.Name, replacement)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isMigratedCommonHelperPath(path string) bool {
|
||||
p := strings.ReplaceAll(path, "\\", "/")
|
||||
for _, prefix := range migratedCommonHelperPaths {
|
||||
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveCommonNames(file *ast.File) (map[string]struct{}, bool) {
|
||||
names := make(map[string]struct{})
|
||||
dotImported := false
|
||||
for _, imp := range file.Imports {
|
||||
if imp.Path == nil {
|
||||
continue
|
||||
}
|
||||
p := strings.Trim(imp.Path.Value, "`\"")
|
||||
if p != commonImportPath {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case imp.Name == nil:
|
||||
names["common"] = struct{}{}
|
||||
case imp.Name.Name == ".":
|
||||
dotImported = true
|
||||
case imp.Name.Name == "_":
|
||||
default:
|
||||
names[imp.Name.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return names, dotImported
|
||||
}
|
||||
@@ -877,123 +877,3 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
t.Errorf("test files must be skipped, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *testing.T) {
|
||||
helpers := []string{
|
||||
"FlagErrorf",
|
||||
"MutuallyExclusive",
|
||||
"AtLeastOne",
|
||||
"ExactlyOne",
|
||||
"ValidatePageSize",
|
||||
"ValidateChatID",
|
||||
"ValidateUserID",
|
||||
"ValidateSafePath",
|
||||
"RejectDangerousChars",
|
||||
"WrapInputStatError",
|
||||
"WrapSaveErrorByCategory",
|
||||
"ResolveOpenIDs",
|
||||
"HandleApiResult",
|
||||
}
|
||||
for _, helper := range helpers {
|
||||
t.Run(helper, func(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.` + helper + `()
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for %s, got %d: %+v", helper, len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "common."+helper) {
|
||||
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||
src := `package im
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsTypedHelpersOnMigratedPath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.ValidationErrorf("typed")
|
||||
common.MutuallyExclusiveTyped(nil, "a", "b")
|
||||
common.ValidateChatIDTyped("--chat-ids", "oc_abc")
|
||||
common.ResolveOpenIDsTyped("--user-ids", nil, nil)
|
||||
common.WrapSaveErrorTyped(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("typed helpers must pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsAliasedImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
c.FlagErrorf("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for aliased common import, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsDotImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import . "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
FlagErrorf("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for dot-imported common, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsFunctionValueReference(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() error {
|
||||
f := common.FlagErrorf
|
||||
return f("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ func ScanRepo(root string) ([]Violation, error) {
|
||||
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyCommonHelperCall(rel, string(src))...)
|
||||
// Typed-error invariants — self-scope to errs/ + classify.go.
|
||||
all = append(all, CheckNilSafeError(rel, string(src))...)
|
||||
all = append(all, CheckUnwrapSymmetry(rel, string(src))...)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.47",
|
||||
"version": "1.0.46",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockCreate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-create",
|
||||
Description: "Create a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "type", Desc: "resource type", Required: true, Enum: baseBlockTypeEnums},
|
||||
{Name: "name", Desc: "block name", Required: true},
|
||||
{Name: "parent-id", Desc: "folder block id; when omitted, create at root"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type folder --name \"Project Docs\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type table --name \"Tasks\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type docx --name \"Spec\" --parent-id <folder_block_id>",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type dashboard --name \"Metrics\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type workflow --name \"Approval Flow\"",
|
||||
"Creates a folder, table, docx, dashboard, or workflow entry.",
|
||||
"Do not pass null for --parent-id. Omit it to create at the root level.",
|
||||
"Created resources still use their own commands for content operations, such as table/field/record/docx/dashboard/workflow commands.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockCreate(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockCreate(runtime)
|
||||
},
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockDelete = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-delete",
|
||||
Description: "Delete a block",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:block:delete"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
|
||||
"Deletes the block identified by --block-id.",
|
||||
"Recursive folder deletion is not supported. If a folder is not empty, move or delete its children first.",
|
||||
"Different block types may have independent backing resources; deletion follows backend semantics.",
|
||||
"Use +base-block-list first when you need to confirm the target block id.",
|
||||
"If the user already explicitly confirmed this exact delete target, pass --yes without asking again.",
|
||||
},
|
||||
DryRun: dryRunBaseBlockDelete,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockDelete(runtime)
|
||||
},
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockList = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-list",
|
||||
Description: "List blocks in a base",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:block:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "type", Desc: "filter by resource type", Enum: baseBlockTypeEnums},
|
||||
{Name: "parent-id", Desc: "folder block id; when omitted, list all blocks"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token>",
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token> --type table",
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
|
||||
`JQ crop: lark-cli base +base-block-list --base-token <base_token> | jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
|
||||
`JQ crop docx: lark-cli base +base-block-list --base-token <base_token> --type docx | jq '.blocks[] | {name, docx_token}'`,
|
||||
"Blocks are resources managed directly by the base, such as folder, table, docx, dashboard, and workflow.",
|
||||
"For table, dashboard, and workflow blocks, returned id is the table-id, dashboard-id, or workflow-id used by the corresponding commands.",
|
||||
"For docx blocks, use the returned docx_token with docx commands.",
|
||||
"For folder blocks, pass the returned id as --parent-id when creating, listing, or moving blocks inside that folder.",
|
||||
"This command returns the full backend list. It intentionally does not expose limit or offset.",
|
||||
"Pass --type to list only one resource type.",
|
||||
"Pass --parent-id to list only direct children of a folder.",
|
||||
"Dashboard blocks are chart/widget blocks inside a dashboard; use +dashboard-block-* for those.",
|
||||
},
|
||||
DryRun: dryRunBaseBlockList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockList(runtime)
|
||||
},
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockMove = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-move",
|
||||
Description: "Move a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
{Name: "parent-id", Desc: "target folder block id; when omitted, move to root"},
|
||||
{Name: "before-id", Desc: "sibling block id; move the block before this sibling in the target folder/root order"},
|
||||
{Name: "after-id", Desc: "sibling block id; move the block after this sibling in the target folder/root order"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
|
||||
"Omit --parent-id to move the block to root; do not pass null.",
|
||||
"--before-id and --after-id are mutually exclusive.",
|
||||
"When moving a folder, its children remain under that folder.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockMove(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockMove,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockMove(runtime)
|
||||
},
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var baseBlockTypeEnums = []string{"folder", "table", "docx", "dashboard", "workflow"}
|
||||
|
||||
func baseBlockIDFlag(required bool) common.Flag {
|
||||
return common.Flag{Name: "block-id", Desc: "block id", Required: required}
|
||||
}
|
||||
|
||||
func dryRunBaseBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
|
||||
Body(buildBaseBlockListBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks").
|
||||
Body(buildBaseBlockCreateBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockMove(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/move").
|
||||
Body(buildBaseBlockMoveBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockRename(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/rename").
|
||||
Body(map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/blocks/:block_id").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return common.FlagErrorf("--name must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||
return common.FlagErrorf("--type must not be blank")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseBlockMove(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
|
||||
return common.FlagErrorf("--before-id and --after-id are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return common.FlagErrorf("--name must not be blank")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockList(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", "list"), nil, buildBaseBlockListBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filterBaseBlockListData(data, strings.TrimSpace(runtime.Str("type")))
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockCreate(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks"), nil, buildBaseBlockCreateBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "created": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockMove(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "move"), nil, buildBaseBlockMoveBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "moved": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockRename(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "rename"), nil, map[string]interface{}{
|
||||
"name": strings.TrimSpace(runtime.Str("name")),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "renamed": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockDelete(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "deleted": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBaseBlockListBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func filterBaseBlockListData(data map[string]interface{}, blockType string) {
|
||||
if blockType == "" {
|
||||
return
|
||||
}
|
||||
blocks, ok := data["blocks"].([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filtered := make([]interface{}, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
blockMap, ok := block.(map[string]interface{})
|
||||
if !ok || blockMap["type"] != blockType {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, block)
|
||||
}
|
||||
data["blocks"] = filtered
|
||||
data["total"] = len(filtered)
|
||||
}
|
||||
|
||||
func buildBaseBlockCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"type": strings.TrimSpace(runtime.Str("type")),
|
||||
"name": strings.TrimSpace(runtime.Str("name")),
|
||||
}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func buildBaseBlockMoveBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{"parent_id": nil}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
if beforeID := strings.TrimSpace(runtime.Str("before-id")); beforeID != "" {
|
||||
body["before_id"] = beforeID
|
||||
}
|
||||
if afterID := strings.TrimSpace(runtime.Str("after-id")); afterID != "" {
|
||||
body["after_id"] = afterID
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockRename = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-rename",
|
||||
Description: "Rename a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
{Name: "name", Desc: "new unique block name; must not duplicate another block name in this base", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name \"New name\"",
|
||||
"Renames the block identified by --block-id.",
|
||||
"Block names must be unique in the base; use +base-block-list first when you need to check existing names.",
|
||||
"Use +base-block-list first when you need to resolve the target block id from a visible name.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockRename(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockRename,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockRename(runtime)
|
||||
},
|
||||
}
|
||||
@@ -32,29 +32,6 @@ func TestDryRunTableOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1")
|
||||
}
|
||||
|
||||
func TestDryRunBaseBlockOps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockList(ctx, listRT), "POST /open-apis/base/v3/bases/app_x/blocks/list")
|
||||
|
||||
listFolderRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "parent-id": "bfl_1", "type": "docx"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockList(ctx, listFolderRT), "POST /open-apis/base/v3/bases/app_x/blocks/list", `"parent_id":"bfl_1"`)
|
||||
|
||||
createRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "type": "docx", "name": "Spec", "parent-id": "bfl_1"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockCreate(ctx, createRT), "POST /open-apis/base/v3/bases/app_x/blocks", `"type":"docx"`, `"name":"Spec"`, `"parent_id":"bfl_1"`)
|
||||
|
||||
moveRootRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveRootRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":null`)
|
||||
|
||||
moveAfterRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "parent-id": "bfl_1", "after-id": "blk_0"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveAfterRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":"bfl_1"`, `"after_id":"blk_0"`)
|
||||
|
||||
renameRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "name": "New name"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockRename(ctx, renameRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/rename", `"name":"New name"`)
|
||||
assertDryRunContains(t, dryRunBaseBlockDelete(ctx, renameRT), "DELETE /open-apis/base/v3/bases/app_x/blocks/blk_1")
|
||||
}
|
||||
|
||||
func TestDryRunFieldOps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -411,108 +411,6 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBaseBlockExecuteShortcuts(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
listStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/list",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec"},
|
||||
map[string]interface{}{"id": "blk_folder", "type": "folder", "name": "Folder"},
|
||||
},
|
||||
"total": 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "type": "docx", "name": "Spec"},
|
||||
},
|
||||
}
|
||||
moveStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "parent_id": "bfl_1"},
|
||||
},
|
||||
}
|
||||
renameStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/rename",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "name": "Final Spec"},
|
||||
},
|
||||
}
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc"},
|
||||
},
|
||||
}
|
||||
for _, stub := range []*httpmock.Stub{listStub, createStub, moveStub, renameStub, deleteStub} {
|
||||
reg.Register(stub)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockList, []string{"+base-block-list", "--base-token", "app_x", "--parent-id", "bfl_1", "--type", "docx"}, factory, stdout); err != nil {
|
||||
t.Fatalf("list err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 1`) || !strings.Contains(got, `"blk_doc"`) || strings.Contains(got, `"blk_folder"`) {
|
||||
t.Fatalf("list stdout=%s", got)
|
||||
}
|
||||
if body := decodeCapturedJSONBody(t, listStub); body["parent_id"] != "bfl_1" || body["type"] != nil {
|
||||
t.Fatalf("list body=%#v", body)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockCreate, []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " Spec ", "--parent-id", "bfl_1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("create err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"blk_doc"`) {
|
||||
t.Fatalf("create stdout=%s", got)
|
||||
}
|
||||
createBody := decodeCapturedJSONBody(t, createStub)
|
||||
if createBody["type"] != "docx" || createBody["name"] != "Spec" || createBody["parent_id"] != "bfl_1" {
|
||||
t.Fatalf("create body=%#v", createBody)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockMove, []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--parent-id", "bfl_1", "--after-id", "blk_prev"}, factory, stdout); err != nil {
|
||||
t.Fatalf("move err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"moved": true`) {
|
||||
t.Fatalf("move stdout=%s", got)
|
||||
}
|
||||
moveBody := decodeCapturedJSONBody(t, moveStub)
|
||||
if moveBody["parent_id"] != "bfl_1" || moveBody["after_id"] != "blk_prev" {
|
||||
t.Fatalf("move body=%#v", moveBody)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockRename, []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " Final Spec "}, factory, stdout); err != nil {
|
||||
t.Fatalf("rename err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"renamed": true`) || !strings.Contains(got, `"Final Spec"`) {
|
||||
t.Fatalf("rename stdout=%s", got)
|
||||
}
|
||||
if body := decodeCapturedJSONBody(t, renameStub); body["name"] != "Final Spec" {
|
||||
t.Fatalf("rename body=%#v", body)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockDelete, []string{"+base-block-delete", "--base-token", "app_x", "--block-id", "blk_doc", "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("delete err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"blk_doc"`) {
|
||||
t.Fatalf("delete stdout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseHistoryExecute(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -133,7 +133,6 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
func TestShortcutsCatalog(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
want := []string{
|
||||
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
@@ -189,7 +188,6 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
|
||||
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
|
||||
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
|
||||
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
|
||||
BaseBaseBlockDelete.Command: BaseBaseBlockDelete.Risk,
|
||||
BaseRoleDelete.Command: BaseRoleDelete.Risk,
|
||||
}
|
||||
|
||||
@@ -243,30 +241,6 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockMoveRejectsBeforeAndAfter(t *testing.T) {
|
||||
runtime := newBaseTestRuntime(
|
||||
map[string]string{"before-id": "blk_before", "after-id": "blk_after"},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
err := validateBaseBlockMove(runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--before-id and --after-id are mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockCreateAndRenameRequireName(t *testing.T) {
|
||||
createRT := newBaseTestRuntime(map[string]string{"type": "folder", "name": " "}, nil, nil)
|
||||
if err := validateBaseBlockCreate(createRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
|
||||
t.Fatalf("create err=%v", err)
|
||||
}
|
||||
|
||||
renameRT := newBaseTestRuntime(map[string]string{"name": " "}, nil, nil)
|
||||
if err := validateBaseBlockRename(renameRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
|
||||
t.Fatalf("rename err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -754,79 +728,6 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
shortcut: BaseBaseBlockList,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-list --base-token <base_token>",
|
||||
"lark-cli base +base-block-list --base-token <base_token> --type table",
|
||||
"lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
|
||||
`jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
|
||||
`--type docx | jq '.blocks[] | {name, docx_token}'`,
|
||||
"returned id is the table-id, dashboard-id, or workflow-id",
|
||||
"For docx blocks, use the returned docx_token with docx commands.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
shortcut: BaseBaseBlockCreate,
|
||||
wantTips: []string{
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type folder --name "Project Docs"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type table --name "Tasks"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type docx --name "Spec" --parent-id <folder_block_id>`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type dashboard --name "Metrics"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type workflow --name "Approval Flow"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "move",
|
||||
shortcut: BaseBaseBlockMove,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rename",
|
||||
shortcut: BaseBaseBlockRename,
|
||||
wantTips: []string{
|
||||
`lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name "New name"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
shortcut: BaseBaseBlockDelete,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
|
||||
"Recursive folder deletion is not supported.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
|
||||
@@ -8,11 +8,6 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
// Shortcuts returns all base shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
BaseBaseBlockList,
|
||||
BaseBaseBlockCreate,
|
||||
BaseBaseBlockMove,
|
||||
BaseBaseBlockRename,
|
||||
BaseBaseBlockDelete,
|
||||
BaseTableList,
|
||||
BaseTableGet,
|
||||
BaseTableCreate,
|
||||
|
||||
44
shortcuts/common/argstype/chat_id.go
Normal file
44
shortcuts/common/argstype/chat_id.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ChatID is a typed Lark chat identifier with the "oc_" prefix.
|
||||
// Satisfies common.Validatable.
|
||||
type ChatID string
|
||||
|
||||
// ValidateValue checks the oc_ prefix and trims whitespace. Empty values are
|
||||
// rejected here even though required-ness is enforced by the binder; this
|
||||
// keeps the type safe to call as a standalone validator.
|
||||
func (id ChatID) ValidateValue(_ *common.RuntimeContext, flagName string) error {
|
||||
s := strings.TrimSpace(string(id))
|
||||
if s == "" {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "chat ID is required",
|
||||
Hint: "pass --chat-id oc_xxx",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(s, "oc_") {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "invalid chat ID format: expected oc_xxx",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
47
shortcuts/common/argstype/chat_id_test.go
Normal file
47
shortcuts/common/argstype/chat_id_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestChatID_ValidatePass(t *testing.T) {
|
||||
id := ChatID("oc_abc123")
|
||||
if err := id.ValidateValue(nil, "chat-id"); err != nil {
|
||||
t.Errorf("oc_ prefix should pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatID_ValidateReject(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"wrong prefix", "ou_abc"},
|
||||
{"random", "abc123"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ChatID(tt.v).ValidateValue(nil, "chat-id")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want invalid_argument", ve.Subtype)
|
||||
}
|
||||
if ve.Param != "chat-id" {
|
||||
t.Errorf("Param = %q, want chat-id", ve.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
38
shortcuts/common/argstype/media_input.go
Normal file
38
shortcuts/common/argstype/media_input.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MediaInput is the tri-state media-field value used by im image/file/video/
|
||||
// audio flags: URL, "img_xxx"/"file_xxx" key, or cwd-relative local path.
|
||||
// URL and key forms bypass path safety checks; local paths go through the
|
||||
// same SafePath rules. Does not emit absolute paths in hints (log safety).
|
||||
type MediaInput string
|
||||
|
||||
// IsURL reports whether the value looks like an http(s) URL.
|
||||
func (m MediaInput) IsURL() bool {
|
||||
s := string(m)
|
||||
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||
}
|
||||
|
||||
// IsMediaKey reports whether the value is an already-uploaded media key.
|
||||
func (m MediaInput) IsMediaKey() bool {
|
||||
s := string(m)
|
||||
return strings.HasPrefix(s, "img_") || strings.HasPrefix(s, "file_")
|
||||
}
|
||||
|
||||
func (m MediaInput) ValidateValue(rt *common.RuntimeContext, flagName string) error {
|
||||
if string(m) == "" {
|
||||
return nil
|
||||
}
|
||||
if m.IsURL() || m.IsMediaKey() {
|
||||
return nil
|
||||
}
|
||||
return SafePath(m).ValidateValue(rt, flagName)
|
||||
}
|
||||
36
shortcuts/common/argstype/media_input_test.go
Normal file
36
shortcuts/common/argstype/media_input_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMediaInput_AcceptURL(t *testing.T) {
|
||||
for _, v := range []string{"https://example.com/x.png", "http://a.b/y"} {
|
||||
if err := MediaInput(v).ValidateValue(nil, "image"); err != nil {
|
||||
t.Errorf("URL %q should pass: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaInput_AcceptKey(t *testing.T) {
|
||||
for _, v := range []string{"img_abc123", "file_xyz"} {
|
||||
if err := MediaInput(v).ValidateValue(nil, "image"); err != nil {
|
||||
t.Errorf("key %q should pass: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaInput_RejectAbsolutePath(t *testing.T) {
|
||||
if err := MediaInput("/etc/passwd").ValidateValue(nil, "image"); err == nil {
|
||||
t.Fatal("absolute path must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaInput_AcceptRelativePath(t *testing.T) {
|
||||
if err := MediaInput("./pic.png").ValidateValue(nil, "image"); err != nil {
|
||||
t.Errorf("relative path should pass: %v", err)
|
||||
}
|
||||
}
|
||||
63
shortcuts/common/argstype/safe_path.go
Normal file
63
shortcuts/common/argstype/safe_path.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SafePath is a strict cwd-relative file path. Absolute paths and ".."
|
||||
// segments are rejected. Does NOT emit absolute path back to stderr in any
|
||||
// hint (log safety).
|
||||
type SafePath string
|
||||
|
||||
func (p SafePath) ValidateValue(_ *common.RuntimeContext, flagName string) error {
|
||||
s := string(p)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if filepath.IsAbs(s) {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "path must be cwd-relative; absolute paths are rejected",
|
||||
Hint: "use a relative path like ./file.txt",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
// Check the RAW input for ".." segments before filepath.Clean collapses
|
||||
// them — Clean turns "a/../b" into "b", which would otherwise hide a
|
||||
// parent-traversal segment the user actually typed. Split on both
|
||||
// separators so "\.." on Windows-style input is caught too.
|
||||
for _, seg := range strings.FieldsFunc(s, func(r rune) bool { return r == '/' || r == '\\' }) {
|
||||
if seg == ".." {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "path must not contain '..' segments",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
}
|
||||
clean := filepath.Clean(s)
|
||||
if strings.HasPrefix(clean, "..") || strings.Contains(clean, "/../") || strings.Contains(clean, `\..\`) {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "path must not contain '..' segments",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
47
shortcuts/common/argstype/safe_path_test.go
Normal file
47
shortcuts/common/argstype/safe_path_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestSafePath_AcceptRelative(t *testing.T) {
|
||||
for _, p := range []string{"local.txt", "./sub/dir/x", "a/b/c"} {
|
||||
if err := SafePath(p).ValidateValue(nil, "file"); err != nil {
|
||||
t.Errorf("%q should pass, got: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePath_RejectAbsolute(t *testing.T) {
|
||||
err := SafePath("/etc/passwd").ValidateValue(nil, "file")
|
||||
if err == nil {
|
||||
t.Fatal("absolute path must be rejected")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "file" {
|
||||
t.Errorf("wrong wrap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePath_RejectDotDot(t *testing.T) {
|
||||
err := SafePath("../leak").ValidateValue(nil, "file")
|
||||
if err == nil {
|
||||
t.Fatal("'..' segment must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePath_RejectMidPathDotDot(t *testing.T) {
|
||||
// filepath.Clean collapses "a/../b" to "b"; the raw-segment scan must
|
||||
// still reject it because the user literally typed a parent segment.
|
||||
for _, p := range []string{"a/../b", "sub/../../etc", `win\..\x`} {
|
||||
if err := SafePath(p).ValidateValue(nil, "file"); err == nil {
|
||||
t.Errorf("%q should be rejected (raw .. segment)", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
shortcuts/common/argstype/spreadsheet_ref.go
Normal file
74
shortcuts/common/argstype/spreadsheet_ref.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SpreadsheetRef is a Lark Sheets reference: either a raw "shtcn..." token
|
||||
// or a feishu.cn/larksuite.com URL containing one. Normalize extracts the
|
||||
// token from URLs; ValidateValue checks the final token shape. URL→token is
|
||||
// a business-canonicalisation hint and is safe to emit to stderr.
|
||||
type SpreadsheetRef string
|
||||
|
||||
func (s SpreadsheetRef) Normalize(_ context.Context, raw string) (SpreadsheetRef, []string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
if !strings.Contains(trimmed, "://") {
|
||||
return SpreadsheetRef(trimmed), nil, nil
|
||||
}
|
||||
for _, seg := range strings.Split(trimmed, "/") {
|
||||
if strings.HasPrefix(seg, "shtcn") {
|
||||
// Strip any ?query or #fragment suffix so a URL like
|
||||
// .../shtcnXXX?sheet=0#row=5 yields a clean token, not one
|
||||
// polluted by trailing parameters that would later pass the
|
||||
// prefix-only ValidateValue check.
|
||||
token := seg
|
||||
if i := strings.IndexAny(token, "?#"); i >= 0 {
|
||||
token = token[:i]
|
||||
}
|
||||
return SpreadsheetRef(token), []string{"extracted spreadsheet token from URL"}, nil
|
||||
}
|
||||
}
|
||||
return "", nil, &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "URL does not contain a recognisable spreadsheet token",
|
||||
},
|
||||
Param: "",
|
||||
}
|
||||
}
|
||||
|
||||
func (s SpreadsheetRef) ValidateValue(_ *common.RuntimeContext, flagName string) error {
|
||||
v := strings.TrimSpace(string(s))
|
||||
if v == "" {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "spreadsheet reference is required",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(v, "shtcn") {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "spreadsheet token must start with 'shtcn'",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
64
shortcuts/common/argstype/spreadsheet_ref_test.go
Normal file
64
shortcuts/common/argstype/spreadsheet_ref_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpreadsheetRef_NormalizeFromURL(t *testing.T) {
|
||||
url := "https://feishu.cn/sheets/shtcnAbCdEf01234567"
|
||||
v, hints, err := SpreadsheetRef(url).Normalize(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize error: %v", err)
|
||||
}
|
||||
if string(v) != "shtcnAbCdEf01234567" {
|
||||
t.Errorf("expected extracted token, got %q", v)
|
||||
}
|
||||
if len(hints) == 0 {
|
||||
t.Errorf("expected at least one hint about URL extraction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadsheetRef_NormalizeStripsQueryAndFragment(t *testing.T) {
|
||||
for _, url := range []string{
|
||||
"https://feishu.cn/sheets/shtcnAbCdEf01234567?sheet=0",
|
||||
"https://feishu.cn/sheets/shtcnAbCdEf01234567#row=5",
|
||||
"https://feishu.cn/sheets/shtcnAbCdEf01234567?sheet=0#row=5",
|
||||
} {
|
||||
v, _, err := SpreadsheetRef(url).Normalize(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize(%q) error: %v", url, err)
|
||||
}
|
||||
if string(v) != "shtcnAbCdEf01234567" {
|
||||
t.Errorf("Normalize(%q): expected clean token, got %q", url, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadsheetRef_NormalizePassThroughToken(t *testing.T) {
|
||||
v, hints, err := SpreadsheetRef("shtcnXyz").Normalize(context.Background(), "shtcnXyz")
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize error: %v", err)
|
||||
}
|
||||
if string(v) != "shtcnXyz" {
|
||||
t.Errorf("raw token should pass through, got %q", v)
|
||||
}
|
||||
if len(hints) != 0 {
|
||||
t.Errorf("no hint expected for raw token, got %v", hints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadsheetRef_ValidateValueRejectsEmpty(t *testing.T) {
|
||||
if err := SpreadsheetRef("").ValidateValue(nil, "spreadsheet"); err == nil {
|
||||
t.Fatal("empty value should fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadsheetRef_ValidateValueAcceptsToken(t *testing.T) {
|
||||
if err := SpreadsheetRef("shtcnAbCd").ValidateValue(nil, "spreadsheet"); err != nil {
|
||||
t.Errorf("token should pass: %v", err)
|
||||
}
|
||||
}
|
||||
40
shortcuts/common/argstype/user_open_id.go
Normal file
40
shortcuts/common/argstype/user_open_id.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// UserOpenID is a typed Lark user identifier with the "ou_" prefix.
|
||||
type UserOpenID string
|
||||
|
||||
func (id UserOpenID) ValidateValue(_ *common.RuntimeContext, flagName string) error {
|
||||
s := strings.TrimSpace(string(id))
|
||||
if s == "" {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "user open_id is required",
|
||||
Hint: "pass --user-id ou_xxx",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(s, "ou_") {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "invalid user open_id format: expected ou_xxx",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
shortcuts/common/argstype/user_open_id_test.go
Normal file
31
shortcuts/common/argstype/user_open_id_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestUserOpenID_ValidatePass(t *testing.T) {
|
||||
if err := UserOpenID("ou_abc").ValidateValue(nil, "user-id"); err != nil {
|
||||
t.Errorf("ou_ prefix should pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserOpenID_ValidateReject(t *testing.T) {
|
||||
for _, v := range []string{"", "oc_abc", "abc"} {
|
||||
err := UserOpenID(v).ValidateValue(nil, "user-id")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for %q", v)
|
||||
continue
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "user-id" {
|
||||
t.Errorf("wrong wrap for %q: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
795
shortcuts/common/binder.go
Normal file
795
shortcuts/common/binder.go
Normal file
@@ -0,0 +1,795 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// fieldSpec is the binder's intermediate representation of one Args field.
|
||||
// It captures both reflection metadata and the parsed tag values so later
|
||||
// binder stages don't repeat the parsing work.
|
||||
type fieldSpec struct {
|
||||
GoFieldName string
|
||||
FlagName string
|
||||
Description string
|
||||
DefaultValue string
|
||||
EnumValues []string
|
||||
Input []string
|
||||
Required bool
|
||||
Hidden bool
|
||||
NoSplit bool
|
||||
IsOneOfBkt bool
|
||||
IsGroup bool
|
||||
IsPtr bool
|
||||
FieldType reflect.Type
|
||||
StructType reflect.Type
|
||||
}
|
||||
|
||||
// walkArgs reflects an Args struct (must be *T where T is struct) and
|
||||
// returns one fieldSpec per top-level field. Duplicate flag tags inside
|
||||
// the same Args struct panic at Mount time — cross-shortcut duplicates are
|
||||
// not checked (cobra's own per-command Add check covers that).
|
||||
func walkArgs(t reflect.Type) ([]fieldSpec, error) {
|
||||
if t.Kind() != reflect.Ptr || t.Elem().Kind() != reflect.Struct {
|
||||
return nil, fmt.Errorf("Args must be *struct, got %s", t)
|
||||
}
|
||||
st := t.Elem()
|
||||
specs := make([]fieldSpec, 0, st.NumField())
|
||||
seen := map[string]string{}
|
||||
for i := 0; i < st.NumField(); i++ {
|
||||
f := st.Field(i)
|
||||
spec, err := parseFieldSpec(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec.FlagName != "" {
|
||||
if owner, dup := seen[spec.FlagName]; dup {
|
||||
panic(fmt.Sprintf("duplicate flag tag %q in Args struct: fields %s and %s",
|
||||
spec.FlagName, owner, f.Name))
|
||||
}
|
||||
seen[spec.FlagName] = f.Name
|
||||
}
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
// parseFieldSpec extracts the relevant tag values from one struct field.
|
||||
// Sub-struct (OneOf bucket / group) detection is delegated to caller stages;
|
||||
// here we only set flags about the field shape.
|
||||
func parseFieldSpec(f reflect.StructField) (fieldSpec, error) {
|
||||
spec := fieldSpec{
|
||||
GoFieldName: f.Name,
|
||||
FlagName: f.Tag.Get("flag"),
|
||||
Description: f.Tag.Get("desc"),
|
||||
DefaultValue: f.Tag.Get("default"),
|
||||
}
|
||||
if enum := f.Tag.Get("enum"); enum != "" {
|
||||
spec.EnumValues = strings.Split(enum, ",")
|
||||
}
|
||||
if _, has := f.Tag.Lookup("required"); has {
|
||||
spec.Required = true
|
||||
}
|
||||
if input := f.Tag.Get("input"); input != "" {
|
||||
for _, src := range strings.Split(input, ",") {
|
||||
switch src = strings.TrimSpace(src); src {
|
||||
case "":
|
||||
// tolerate stray commas
|
||||
case File, Stdin:
|
||||
spec.Input = append(spec.Input, src)
|
||||
default:
|
||||
return spec, fmt.Errorf("field %s: unknown input source %q (allowed: %q, %q)", f.Name, src, File, Stdin)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, has := f.Tag.Lookup("hidden"); has {
|
||||
spec.Hidden = true
|
||||
}
|
||||
switch split := strings.TrimSpace(f.Tag.Get("split")); split {
|
||||
case "", "comma":
|
||||
// default: cobra StringSlice (comma-separated, also repeatable)
|
||||
case "none":
|
||||
spec.NoSplit = true // cobra StringArray: repeatable, no comma split
|
||||
default:
|
||||
return spec, fmt.Errorf("field %s: unknown split mode %q (allowed: comma, none)", f.Name, split)
|
||||
}
|
||||
ft := f.Type
|
||||
spec.FieldType = ft
|
||||
if ft.Kind() == reflect.Ptr {
|
||||
spec.IsPtr = true
|
||||
ft = ft.Elem()
|
||||
}
|
||||
if ft.Kind() == reflect.Struct {
|
||||
spec.StructType = ft
|
||||
ptr := reflect.PointerTo(ft)
|
||||
marker := reflect.TypeOf((*OneOfMarker)(nil)).Elem()
|
||||
if ft.Implements(marker) || ptr.Implements(marker) {
|
||||
spec.IsOneOfBkt = true
|
||||
} else {
|
||||
spec.IsGroup = true
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
// Leaf field validation. Multi-value flags are []string (cobra
|
||||
// StringSlice / StringArray); any other slice element type is unsupported.
|
||||
// enum / input only make sense on plain string leaves (string or a
|
||||
// string-alias like ChatID); split only on []string. Reject mismatches at
|
||||
// Mount time instead of silently skipping or panicking at runtime.
|
||||
isStringSlice := ft.Kind() == reflect.Slice && ft.Elem().Kind() == reflect.String
|
||||
if ft.Kind() == reflect.Slice && !isStringSlice {
|
||||
return spec, fmt.Errorf("field %s: only []string slices are supported, got %s", f.Name, ft)
|
||||
}
|
||||
if spec.NoSplit && !isStringSlice {
|
||||
return spec, fmt.Errorf("field %s: split tag is only supported on []string fields", f.Name)
|
||||
}
|
||||
if ft.Kind() != reflect.String {
|
||||
if len(spec.EnumValues) > 0 {
|
||||
return spec, fmt.Errorf("field %s: enum tag is only supported on string fields", f.Name)
|
||||
}
|
||||
if len(spec.Input) > 0 {
|
||||
return spec, fmt.Errorf("field %s: input tag is only supported on string fields", f.Name)
|
||||
}
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// registerFlags registers cobra flags for the given specs. Sub-struct fields
|
||||
// (OneOf bucket / group) recurse into their inner fields.
|
||||
func registerFlags(cmd *cobra.Command, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerFlags(cmd, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := registerLeaf(cmd, s, s.FieldType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerLeaf registers a single primitive flag based on the underlying type.
|
||||
func registerLeaf(cmd *cobra.Command, s fieldSpec, t reflect.Type) error {
|
||||
if s.FlagName == "" {
|
||||
return nil
|
||||
}
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Bool:
|
||||
def := s.DefaultValue == "true"
|
||||
cmd.Flags().Bool(s.FlagName, def, s.Description)
|
||||
case reflect.Int, reflect.Int64:
|
||||
def := 0
|
||||
if s.DefaultValue != "" {
|
||||
def, _ = strconv.Atoi(s.DefaultValue)
|
||||
}
|
||||
cmd.Flags().Int(s.FlagName, def, s.Description)
|
||||
case reflect.Slice:
|
||||
// []string (validated at parse time). NoSplit → StringArray
|
||||
// (repeatable, literal); default → StringSlice (comma-separated).
|
||||
if s.NoSplit {
|
||||
cmd.Flags().StringArray(s.FlagName, nil, s.Description)
|
||||
} else {
|
||||
cmd.Flags().StringSlice(s.FlagName, nil, s.Description)
|
||||
}
|
||||
default:
|
||||
cmd.Flags().String(s.FlagName, s.DefaultValue, s.Description)
|
||||
}
|
||||
if s.Required {
|
||||
_ = cmd.MarkFlagRequired(s.FlagName)
|
||||
}
|
||||
if s.Hidden {
|
||||
_ = cmd.Flags().MarkHidden(s.FlagName)
|
||||
}
|
||||
if len(s.EnumValues) > 0 {
|
||||
vals := s.EnumValues
|
||||
cmdutil.RegisterFlagCompletion(cmd, s.FlagName, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return vals, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveTypedInputs applies @file / stdin resolution to every leaf flag that
|
||||
// declared an `input:"file,stdin"` tag, recursing into OneOf buckets and groups
|
||||
// (cobra flags are flat, so a nested variant's flag resolves the same way). It
|
||||
// runs before bindFlags so the file/stdin content is read back into the cobra
|
||||
// flag and then bound into the Args struct. This is the typed counterpart of
|
||||
// runShortcut's resolveInputFlags, which only sees the legacy shell's (empty)
|
||||
// Flags slice — both ultimately call resolveInputForFlag.
|
||||
func resolveTypedInputs(rctx *RuntimeContext, specs []fieldSpec) error {
|
||||
stdinUsed := false
|
||||
return resolveTypedInputsRec(rctx, specs, &stdinUsed)
|
||||
}
|
||||
|
||||
func resolveTypedInputsRec(rctx *RuntimeContext, specs []fieldSpec, stdinUsed *bool) error {
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveTypedInputsRec(rctx, inner, stdinUsed); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" || len(s.Input) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := resolveInputForFlag(rctx, s.FlagName, s.Input, stdinUsed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindFlags writes cobra-parsed values back into the Args struct. argsVal
|
||||
// is a reflect.Value of the struct (not pointer).
|
||||
func bindFlags(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" {
|
||||
continue
|
||||
}
|
||||
if err := bindLeaf(cmd, argsVal, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindLeaf(cmd *cobra.Command, argsVal reflect.Value, s fieldSpec) error {
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.CanSet() {
|
||||
return nil
|
||||
}
|
||||
// Pointer leaves preserve "nil = not given" semantics: only allocate when
|
||||
// the user explicitly set the flag. This mirrors the OneOf bucket
|
||||
// convention (`Chat *ChatID` — nil means the variant wasn't selected) and
|
||||
// lets typed shortcuts express tri-state bool / int / string flags using
|
||||
// plain Go pointers instead of a separate Maybe[T] wrapper type.
|
||||
if s.IsPtr && !cmd.Flags().Changed(s.FlagName) {
|
||||
return nil
|
||||
}
|
||||
leafType := s.FieldType
|
||||
if leafType.Kind() == reflect.Ptr {
|
||||
leafType = leafType.Elem()
|
||||
}
|
||||
switch leafType.Kind() {
|
||||
case reflect.Bool:
|
||||
v, _ := cmd.Flags().GetBool(s.FlagName)
|
||||
setLeaf(fv, reflect.ValueOf(v))
|
||||
case reflect.Int, reflect.Int64:
|
||||
v, _ := cmd.Flags().GetInt(s.FlagName)
|
||||
setLeaf(fv, reflect.ValueOf(int64(v)).Convert(leafType))
|
||||
case reflect.Slice:
|
||||
var vals []string
|
||||
if s.NoSplit {
|
||||
vals, _ = cmd.Flags().GetStringArray(s.FlagName)
|
||||
} else {
|
||||
vals, _ = cmd.Flags().GetStringSlice(s.FlagName)
|
||||
}
|
||||
setLeaf(fv, stringsToSlice(vals, leafType))
|
||||
default:
|
||||
v, _ := cmd.Flags().GetString(s.FlagName)
|
||||
setLeaf(fv, reflect.ValueOf(v).Convert(leafType))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stringsToSlice builds a slice value of type t (kind Slice with string-kinded
|
||||
// elements) from raw strings, element-by-element via SetString. This handles a
|
||||
// plain []string, a named slice type (type IDs []string), AND a named element
|
||||
// type (type ID string → []ID) — reflect.Convert would panic on the last case
|
||||
// because []string is not convertible to []ID.
|
||||
func stringsToSlice(vals []string, t reflect.Type) reflect.Value {
|
||||
out := reflect.MakeSlice(t, len(vals), len(vals))
|
||||
for i, v := range vals {
|
||||
out.Index(i).SetString(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func setLeaf(dst reflect.Value, src reflect.Value) {
|
||||
if dst.Kind() == reflect.Ptr {
|
||||
ptr := reflect.New(dst.Type().Elem())
|
||||
ptr.Elem().Set(src.Convert(dst.Type().Elem()))
|
||||
dst.Set(ptr)
|
||||
return
|
||||
}
|
||||
dst.Set(src.Convert(dst.Type()))
|
||||
}
|
||||
|
||||
// bindBuckets allocates and populates OneOf bucket / group sub-struct fields
|
||||
// from cobra flag state. The shared bindFlags() above only writes top-level
|
||||
// leaves; this function is the framework's recursion into nested Args structs
|
||||
// so future typed shortcuts don't each have to ship a bespoke binder helper.
|
||||
//
|
||||
// Conventions:
|
||||
// - Pointer-leaf in a bucket (e.g. *string, *argstype.ChatID): set iff
|
||||
// cobra reports the flag was explicitly provided. nil means "variant not
|
||||
// selected" — the framework's runFrameworkRules and runValidateValue
|
||||
// both honor this nil/non-nil split.
|
||||
// - Non-pointer leaf in a group (e.g. a typed-primitive field inside a
|
||||
// paired group struct): always copy the cobra flag value back. Empty
|
||||
// string is a valid "not provided" sentinel for group completeness checks.
|
||||
// - Pointer-to-group / pointer-to-bucket (a nested group/bucket pointer
|
||||
// inside an outer OneOf bucket): allocate iff at least one inner flag was
|
||||
// Changed, then recurse to bind the inner fields.
|
||||
func bindBuckets(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if !s.IsOneOfBkt {
|
||||
continue
|
||||
}
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
target := fv
|
||||
if target.Kind() == reflect.Ptr {
|
||||
if target.IsNil() {
|
||||
target.Set(reflect.New(s.StructType))
|
||||
}
|
||||
target = target.Elem()
|
||||
}
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bindBucketInner(cmd, target, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindGroups is the top-level counterpart to bindBuckets for IsGroup fields
|
||||
// (regular nested structs without an OneOf() marker). A group's inner flags
|
||||
// are registered and validated for completeness, but bindFlags above skips
|
||||
// the field; this function fills the binding gap so an Args struct can place
|
||||
// a group directly at the top level (not just nested inside an OneOf bucket).
|
||||
//
|
||||
// Conventions mirror bindBuckets / bindBucketInner:
|
||||
// - Value-type group: always populated; inner fields receive cobra flag
|
||||
// values (including defaults).
|
||||
// - Pointer-type group: allocated iff at least one inner flag was Changed,
|
||||
// so a nil group still signals "user did not engage this group at all".
|
||||
func bindGroups(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if !s.IsGroup {
|
||||
continue
|
||||
}
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fv.Kind() == reflect.Ptr {
|
||||
anyChanged := false
|
||||
for _, g := range inner {
|
||||
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
|
||||
anyChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyChanged {
|
||||
continue
|
||||
}
|
||||
if fv.IsNil() {
|
||||
fv.Set(reflect.New(s.StructType))
|
||||
}
|
||||
if err := bindBucketInner(cmd, fv.Elem(), inner); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Value-type group: populate inner fields directly.
|
||||
if err := bindBucketInner(cmd, fv, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindBucketInner(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
grandSpecs, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
anyChanged := false
|
||||
for _, g := range grandSpecs {
|
||||
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
|
||||
anyChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyChanged {
|
||||
continue
|
||||
}
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
target := fv
|
||||
if fv.Kind() == reflect.Ptr {
|
||||
if fv.IsNil() {
|
||||
fv.Set(reflect.New(s.StructType))
|
||||
}
|
||||
target = fv.Elem()
|
||||
}
|
||||
if err := bindBucketInner(cmd, target, grandSpecs); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" {
|
||||
continue
|
||||
}
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
if s.IsPtr {
|
||||
if !cmd.Flags().Changed(s.FlagName) {
|
||||
continue
|
||||
}
|
||||
elemType := fv.Type().Elem()
|
||||
ptr := reflect.New(elemType)
|
||||
ptr.Elem().Set(bucketLeafValue(cmd, s.FlagName, elemType, s.NoSplit))
|
||||
fv.Set(ptr)
|
||||
continue
|
||||
}
|
||||
fv.Set(bucketLeafValue(cmd, s.FlagName, fv.Type(), s.NoSplit))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bucketLeafValue reads a single cobra flag and returns it as a reflect.Value
|
||||
// convertible to targetType. It dispatches on the underlying kind so nested
|
||||
// bucket/group leaves typed as bool or int bind correctly instead of being
|
||||
// force-read through GetString (which would panic on reflect conversion of a
|
||||
// string into a numeric/bool type).
|
||||
func bucketLeafValue(cmd *cobra.Command, flagName string, targetType reflect.Type, noSplit bool) reflect.Value {
|
||||
kind := targetType.Kind()
|
||||
if kind == reflect.Ptr {
|
||||
kind = targetType.Elem().Kind()
|
||||
}
|
||||
switch kind {
|
||||
case reflect.Bool:
|
||||
v, _ := cmd.Flags().GetBool(flagName)
|
||||
return reflect.ValueOf(v).Convert(targetType)
|
||||
case reflect.Int, reflect.Int64:
|
||||
v, _ := cmd.Flags().GetInt(flagName)
|
||||
return reflect.ValueOf(int64(v)).Convert(targetType)
|
||||
case reflect.Slice:
|
||||
var vals []string
|
||||
if noSplit {
|
||||
vals, _ = cmd.Flags().GetStringArray(flagName)
|
||||
} else {
|
||||
vals, _ = cmd.Flags().GetStringSlice(flagName)
|
||||
}
|
||||
return stringsToSlice(vals, targetType)
|
||||
default:
|
||||
v, _ := cmd.Flags().GetString(flagName)
|
||||
return reflect.ValueOf(v).Convert(targetType)
|
||||
}
|
||||
}
|
||||
|
||||
// runNormalize invokes the Normalize method (via reflection) on every field
|
||||
// whose type implements Normalizable[T]. The canonical value is written back.
|
||||
// Plan's static type assertion can't work because Normalizable is generic —
|
||||
// the method's return type is the typed T, not any — so we dispatch by
|
||||
// reflection on method shape.
|
||||
//
|
||||
// Local-path normalization hints are dropped at the primitive layer; only
|
||||
// non-path hints reach stderr (log safety).
|
||||
func runNormalize(ctx context.Context, rt *RuntimeContext, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
ctxType := reflect.TypeOf((*context.Context)(nil)).Elem()
|
||||
for _, s := range specs {
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() {
|
||||
continue
|
||||
}
|
||||
m := fv.MethodByName("Normalize")
|
||||
if !m.IsValid() {
|
||||
continue
|
||||
}
|
||||
mt := m.Type()
|
||||
if mt.NumIn() != 2 || mt.NumOut() != 3 {
|
||||
continue
|
||||
}
|
||||
if !ctxType.AssignableTo(mt.In(0)) || mt.In(1).Kind() != reflect.String {
|
||||
continue
|
||||
}
|
||||
raw := asString(fv)
|
||||
rets := m.Call([]reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(raw)})
|
||||
if errRet := rets[2]; !errRet.IsNil() {
|
||||
return errRet.Interface().(error)
|
||||
}
|
||||
canon := rets[0]
|
||||
if fv.CanSet() && canon.Type().AssignableTo(fv.Type()) {
|
||||
fv.Set(canon)
|
||||
}
|
||||
hints, _ := rets[1].Interface().([]string)
|
||||
if len(hints) > 0 && rt != nil && rt.Cmd != nil {
|
||||
for _, h := range hints {
|
||||
_, _ = rt.Cmd.ErrOrStderr().Write([]byte(h + "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func asString(fv reflect.Value) string {
|
||||
if fv.Kind() == reflect.String {
|
||||
return fv.String()
|
||||
}
|
||||
if fv.Kind() == reflect.Ptr && !fv.IsNil() {
|
||||
return asString(fv.Elem())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runValidateValue calls ValidateValue on every Validatable field, recursing
|
||||
// into OneOf bucket / group sub-structs so typed-primitive leaves inside
|
||||
// nested Args structs (e.g. a typed ID primitive inside a OneOf bucket) still
|
||||
// get their format check. Returns the first error to keep error envelopes
|
||||
// deterministic.
|
||||
func runValidateValue(rt *RuntimeContext, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() {
|
||||
continue
|
||||
}
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
structVal := fv
|
||||
if structVal.Kind() == reflect.Ptr {
|
||||
if structVal.IsNil() {
|
||||
continue
|
||||
}
|
||||
structVal = structVal.Elem()
|
||||
}
|
||||
// Some buckets/groups implement Validatable themselves (e.g. a
|
||||
// raw-JSON variant that checks JSON validity in its ValidateValue).
|
||||
// Call the struct-level check BEFORE recursing into inner fields so
|
||||
// the cross-field rule fires even when none of the inner leaves are
|
||||
// individually Validatable.
|
||||
if fv.CanInterface() {
|
||||
if val, ok := fv.Interface().(Validatable); ok {
|
||||
if err := val.ValidateValue(rt, s.FlagName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runValidateValue(rt, structVal, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Leaf field. Skip nil pointers (variant not selected).
|
||||
if fv.Kind() == reflect.Ptr && fv.IsNil() {
|
||||
continue
|
||||
}
|
||||
if !fv.CanInterface() {
|
||||
continue
|
||||
}
|
||||
v := fv.Interface()
|
||||
if val, ok := v.(Validatable); ok {
|
||||
if err := val.ValidateValue(rt, s.FlagName); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Pointer leaf: dereference and re-check (for value-receiver
|
||||
// ValidateValue methods on the pointee type).
|
||||
if fv.Kind() == reflect.Ptr {
|
||||
if val, ok := fv.Elem().Interface().(Validatable); ok {
|
||||
if err := val.ValidateValue(rt, s.FlagName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runFrameworkRules enforces OneOf / group / required / enum invariants and
|
||||
// returns a *errs.ValidationError on the first violation. Each rule's
|
||||
// stderr-facing param is the Args field name (not the inner struct type name),
|
||||
// so OneOf bucket errors mention the user-visible field (e.g. "Target") rather
|
||||
// than the implementation-detail type name behind it.
|
||||
//
|
||||
// Recurses into OneOf bucket sub-structs so a nested group inside a bucket
|
||||
// still gets its checkGroup fire automatically.
|
||||
func runFrameworkRules(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
switch {
|
||||
case s.IsOneOfBkt:
|
||||
if err := checkOneOf(cmd, fv, s); err != nil {
|
||||
return err
|
||||
}
|
||||
structVal := fv
|
||||
if structVal.Kind() == reflect.Ptr {
|
||||
if structVal.IsNil() {
|
||||
continue
|
||||
}
|
||||
structVal = structVal.Elem()
|
||||
}
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runFrameworkRules(cmd, structVal, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
case s.IsGroup:
|
||||
if err := checkGroup(cmd, fv, s); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if err := checkEnumAndRequired(cmd, fv, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkOneOf counts how many variants the user attempted inside the OneOf
|
||||
// bucket; exactly one must be attempted. A variant counts as "attempted" if:
|
||||
// - it's a simple pointer leaf (e.g. *string, *ChatID) and its own flag was
|
||||
// explicitly provided, or
|
||||
// - it's a nested group / bucket and ANY of its inner flags was explicitly
|
||||
// provided. No "trigger" field is required — supplying any flag of a
|
||||
// group is enough to mark the variant as attempted, and a follow-up
|
||||
// checkGroup catches the partial-fill case with shortcut_group_incomplete.
|
||||
func checkOneOf(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var triggered []string
|
||||
for _, child := range inner {
|
||||
// Simple pointer leaf variant: its own flag is the signal.
|
||||
if child.IsPtr && !child.IsOneOfBkt && !child.IsGroup {
|
||||
if child.FlagName != "" && cmd.Flags().Changed(child.FlagName) {
|
||||
triggered = append(triggered, "--"+child.FlagName)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Nested group / bucket variant: any inner flag Changed counts.
|
||||
if child.IsGroup || child.IsOneOfBkt {
|
||||
grand, _ := walkArgs(reflect.PointerTo(child.StructType))
|
||||
for _, g := range grand {
|
||||
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
|
||||
triggered = append(triggered, "--"+g.FlagName)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch len(triggered) {
|
||||
case 0:
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeShortcutOneOfMissing,
|
||||
Message: "exactly one " + s.GoFieldName + " variant must be provided",
|
||||
},
|
||||
Param: s.GoFieldName,
|
||||
}
|
||||
case 1:
|
||||
return nil
|
||||
default:
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeShortcutOneOfMultiple,
|
||||
Message: "choose only one of " + strings.Join(triggered, ", "),
|
||||
},
|
||||
Param: s.GoFieldName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkGroup ensures all fields of a group sub-struct were provided when
|
||||
// the group's trigger (or first field) was set.
|
||||
func checkGroup(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
anySet := false
|
||||
var missing []string
|
||||
for _, child := range inner {
|
||||
if child.FlagName == "" {
|
||||
continue
|
||||
}
|
||||
if cmd.Flags().Changed(child.FlagName) {
|
||||
anySet = true
|
||||
continue
|
||||
}
|
||||
// Flags with a default value are never "missing" — the default is a
|
||||
// valid implicit value (e.g. an enum flag that defaults to a value).
|
||||
// Only flags without defaults need explicit user input when the
|
||||
// group is partially populated.
|
||||
if child.DefaultValue != "" {
|
||||
continue
|
||||
}
|
||||
missing = append(missing, "--"+child.FlagName)
|
||||
}
|
||||
if anySet && len(missing) > 0 {
|
||||
// Group errors use the inner struct TYPE name (the group struct's own
|
||||
// name), not the Args field name. This matches the spec's
|
||||
// "shortcut_group_incomplete" envelope contract — callers identify
|
||||
// the *kind* of group that is incomplete, which is the type name.
|
||||
// OneOf bucket errors use the field name instead (see checkOneOf).
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeShortcutGroupIncomplete,
|
||||
Message: s.StructType.Name() + " requires " + strings.Join(missing, ", "),
|
||||
},
|
||||
Param: s.StructType.Name(),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkEnumAndRequired enforces enum membership. Required-presence is already
|
||||
// enforced at cobra level via MarkFlagRequired, so this only adds the enum
|
||||
// check.
|
||||
func checkEnumAndRequired(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
|
||||
if len(s.EnumValues) == 0 {
|
||||
return nil
|
||||
}
|
||||
v, _ := cmd.Flags().GetString(s.FlagName)
|
||||
if v == "" && s.DefaultValue == "" {
|
||||
return nil
|
||||
}
|
||||
for _, allowed := range s.EnumValues {
|
||||
if v == allowed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "--" + s.FlagName + ": value must be one of " + strings.Join(s.EnumValues, "|"),
|
||||
},
|
||||
Param: s.FlagName,
|
||||
}
|
||||
}
|
||||
294
shortcuts/common/binder_coverage_test.go
Normal file
294
shortcuts/common/binder_coverage_test.go
Normal file
@@ -0,0 +1,294 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// --- top-level pointer leaf: nil = not given (mirrors OneOf bucket convention) ---
|
||||
|
||||
type ptrLeafArgs struct {
|
||||
Notify *bool `flag:"notify"`
|
||||
Limit *int `flag:"limit"`
|
||||
Name *string `flag:"name"`
|
||||
}
|
||||
|
||||
func TestBindLeaf_PtrNilWhenAbsent(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&ptrLeafArgs{}))
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
out := &ptrLeafArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if out.Notify != nil || out.Limit != nil || out.Name != nil {
|
||||
t.Errorf("expected all pointer leaves nil when no flag given; got Notify=%v Limit=%v Name=%v",
|
||||
out.Notify, out.Limit, out.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBindLeaf_PtrSetWhenChanged covers the tri-state contract that previously
|
||||
// required Maybe[T]: a pointer leaf set to its zero value (e.g. --notify=false)
|
||||
// MUST come back non-nil so business code can tell it apart from "not given".
|
||||
func TestBindLeaf_PtrSetWhenChanged(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&ptrLeafArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
if err := cmd.ParseFlags([]string{"--notify=false", "--limit", "5", "--name", "alice"}); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
out := &ptrLeafArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if out.Notify == nil || *out.Notify != false {
|
||||
t.Errorf("Notify = %v, want non-nil false (tri-state: zero value is still 'set')", out.Notify)
|
||||
}
|
||||
if out.Limit == nil || *out.Limit != 5 {
|
||||
t.Errorf("Limit = %v, want non-nil 5", out.Limit)
|
||||
}
|
||||
if out.Name == nil || *out.Name != "alice" {
|
||||
t.Errorf("Name = %v, want non-nil alice", out.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OneOf bucket binding: bindBuckets / bindBucketInner / bucketLeafValue ----
|
||||
|
||||
type bcLeafBucket struct {
|
||||
S *string `flag:"lb-s"`
|
||||
I *int `flag:"lb-i"`
|
||||
B *bool `flag:"lb-b"`
|
||||
Plain string `flag:"lb-plain"` // non-pointer leaf exercises the value branch
|
||||
}
|
||||
|
||||
func (bcLeafBucket) OneOf() {}
|
||||
|
||||
type bcValueBucketArgs struct {
|
||||
Sel bcLeafBucket
|
||||
}
|
||||
|
||||
func TestBindBuckets_ValueBucketTypedLeaves(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcValueBucketArgs{}))
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--lb-s", "hi", "--lb-i", "7", "--lb-b", "--lb-plain", "p"}); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
out := &bcValueBucketArgs{}
|
||||
val := reflect.ValueOf(out).Elem()
|
||||
if err := bindFlags(cmd, val, specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if err := bindBuckets(cmd, val, specs); err != nil {
|
||||
t.Fatalf("bindBuckets: %v", err)
|
||||
}
|
||||
if out.Sel.S == nil || *out.Sel.S != "hi" {
|
||||
t.Errorf("Sel.S = %v, want hi", out.Sel.S)
|
||||
}
|
||||
if out.Sel.I == nil || *out.Sel.I != 7 {
|
||||
t.Errorf("Sel.I = %v, want 7", out.Sel.I)
|
||||
}
|
||||
if out.Sel.B == nil || *out.Sel.B != true {
|
||||
t.Errorf("Sel.B = %v, want true", out.Sel.B)
|
||||
}
|
||||
if out.Sel.Plain != "p" {
|
||||
t.Errorf("Sel.Plain = %q, want p", out.Sel.Plain)
|
||||
}
|
||||
}
|
||||
|
||||
type bcPtrBucketArgs struct {
|
||||
Sel *bcLeafBucket
|
||||
}
|
||||
|
||||
func TestBindBuckets_PointerBucketAllocates(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcPtrBucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
if err := cmd.ParseFlags([]string{"--lb-s", "x"}); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
out := &bcPtrBucketArgs{}
|
||||
val := reflect.ValueOf(out).Elem()
|
||||
if err := bindBuckets(cmd, val, specs); err != nil {
|
||||
t.Fatalf("bindBuckets: %v", err)
|
||||
}
|
||||
if out.Sel == nil {
|
||||
t.Fatal("Sel pointer was not allocated")
|
||||
}
|
||||
if out.Sel.S == nil || *out.Sel.S != "x" {
|
||||
t.Errorf("Sel.S = %v, want x", out.Sel.S)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runNormalize / asString --------------------------------------------------
|
||||
|
||||
type bcNormField string
|
||||
|
||||
func (n bcNormField) Normalize(_ context.Context, raw string) (bcNormField, []string, error) {
|
||||
if raw == "boom" {
|
||||
return "", nil, errors.New("normalize failed")
|
||||
}
|
||||
return bcNormField("c:" + raw), []string{"hint: " + raw}, nil
|
||||
}
|
||||
|
||||
type bcNormArgs struct {
|
||||
Token bcNormField `flag:"token"`
|
||||
TokenPtr *bcNormField `flag:"token-ptr"`
|
||||
}
|
||||
|
||||
func TestRunNormalize_CanonicalizesAndEmitsHints(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.SetErr(&buf)
|
||||
rt := &RuntimeContext{Cmd: cmd}
|
||||
|
||||
ptr := bcNormField("xy")
|
||||
args := &bcNormArgs{Token: "raw", TokenPtr: &ptr}
|
||||
specs, _ := walkArgs(reflect.TypeOf(args))
|
||||
|
||||
if err := runNormalize(context.Background(), rt, reflect.ValueOf(args).Elem(), specs); err != nil {
|
||||
t.Fatalf("runNormalize: %v", err)
|
||||
}
|
||||
if args.Token != "c:raw" {
|
||||
t.Errorf("Token = %q, want c:raw", args.Token)
|
||||
}
|
||||
if got := buf.String(); got == "" || !bytes.Contains([]byte(got), []byte("hint: raw")) {
|
||||
t.Errorf("stderr = %q, want it to contain the normalize hint", got)
|
||||
}
|
||||
}
|
||||
|
||||
type bcBadNormArgs struct {
|
||||
Token bcNormField `flag:"token"`
|
||||
}
|
||||
|
||||
func TestRunNormalize_PropagatesError(t *testing.T) {
|
||||
args := &bcBadNormArgs{Token: "boom"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(args))
|
||||
err := runNormalize(context.Background(), nil, reflect.ValueOf(args).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from Normalize")
|
||||
}
|
||||
}
|
||||
|
||||
// --- checkGroup (via runFrameworkRules) ---------------------------------------
|
||||
|
||||
type bcGroupBody struct {
|
||||
A string `flag:"g-a"`
|
||||
B string `flag:"g-b"`
|
||||
C string `flag:"g-c" default:"x"` // default means never "missing"
|
||||
}
|
||||
|
||||
type bcGroupArgs struct {
|
||||
Grp bcGroupBody
|
||||
}
|
||||
|
||||
func TestCheckGroup_IncompleteReportsMissing(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcGroupArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
// Only --g-a set: B is missing (no default), C has a default so it is fine.
|
||||
_ = cmd.ParseFlags([]string{"--g-a", "v"})
|
||||
out := &bcGroupArgs{}
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected group_incomplete error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutGroupIncomplete {
|
||||
t.Errorf("Subtype = %q, want group_incomplete", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckGroup_CompleteAndUntouched(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcGroupArgs{}))
|
||||
|
||||
// Complete: A and B provided.
|
||||
cmd1 := &cobra.Command{Use: "t1"}
|
||||
_ = registerFlags(cmd1, specs)
|
||||
_ = cmd1.ParseFlags([]string{"--g-a", "1", "--g-b", "2"})
|
||||
if err := runFrameworkRules(cmd1, reflect.ValueOf(&bcGroupArgs{}).Elem(), specs); err != nil {
|
||||
t.Errorf("complete group should pass, got %v", err)
|
||||
}
|
||||
|
||||
// Untouched: nothing set → group rule does not fire.
|
||||
cmd2 := &cobra.Command{Use: "t2"}
|
||||
_ = registerFlags(cmd2, specs)
|
||||
_ = cmd2.ParseFlags(nil)
|
||||
if err := runFrameworkRules(cmd2, reflect.ValueOf(&bcGroupArgs{}).Elem(), specs); err != nil {
|
||||
t.Errorf("untouched group should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- checkEnumAndRequired (via runFrameworkRules) -----------------------------
|
||||
|
||||
type bcEnumArgs struct {
|
||||
Mode string `flag:"mode" enum:"a,b,c"`
|
||||
}
|
||||
|
||||
func TestCheckEnum(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcEnumArgs{}))
|
||||
|
||||
// Invalid value.
|
||||
cmd1 := &cobra.Command{Use: "t1"}
|
||||
_ = registerFlags(cmd1, specs)
|
||||
_ = cmd1.ParseFlags([]string{"--mode", "z"})
|
||||
err := runFrameworkRules(cmd1, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid_argument error for bad enum value")
|
||||
}
|
||||
if ve := mustValidationError(t, err); ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want invalid_argument", ve.Subtype)
|
||||
}
|
||||
|
||||
// Valid value.
|
||||
cmd2 := &cobra.Command{Use: "t2"}
|
||||
_ = registerFlags(cmd2, specs)
|
||||
_ = cmd2.ParseFlags([]string{"--mode", "b"})
|
||||
if err := runFrameworkRules(cmd2, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs); err != nil {
|
||||
t.Errorf("valid enum value should pass, got %v", err)
|
||||
}
|
||||
|
||||
// Empty (no default) → enum check skipped.
|
||||
cmd3 := &cobra.Command{Use: "t3"}
|
||||
_ = registerFlags(cmd3, specs)
|
||||
_ = cmd3.ParseFlags(nil)
|
||||
if err := runFrameworkRules(cmd3, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs); err != nil {
|
||||
t.Errorf("empty enum value should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runValidateValue recursion into a group sub-struct -----------------------
|
||||
|
||||
type bcValGroup struct {
|
||||
ID dummyValidatable `flag:"vg-id"`
|
||||
}
|
||||
|
||||
type bcValGroupArgs struct {
|
||||
Grp bcValGroup
|
||||
}
|
||||
|
||||
func TestRunValidateValue_RecursesIntoGroup(t *testing.T) {
|
||||
args := &bcValGroupArgs{}
|
||||
specs, _ := walkArgs(reflect.TypeOf(args))
|
||||
rt := &RuntimeContext{}
|
||||
if err := runValidateValue(rt, reflect.ValueOf(args).Elem(), specs); err != nil {
|
||||
t.Errorf("runValidateValue into group: %v", err)
|
||||
}
|
||||
}
|
||||
237
shortcuts/common/binder_input_enum_test.go
Normal file
237
shortcuts/common/binder_input_enum_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// newTypedInputRuntime registers the given specs on a fresh cobra command,
|
||||
// parses argv, and returns a RuntimeContext wired with a fake stdin — the
|
||||
// typed-binder analogue of newTestRuntimeWithStdin in runner_input_test.go.
|
||||
func newTypedInputRuntime(t *testing.T, specs []fieldSpec, argv []string, stdin string) *RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags(argv); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
return &RuntimeContext{
|
||||
Cmd: cmd,
|
||||
Factory: &cmdutil.Factory{
|
||||
IOStreams: &cmdutil.IOStreams{In: strings.NewReader(stdin)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- @file / stdin on typed shortcuts -------------------------------------
|
||||
|
||||
type fileInputArgs struct {
|
||||
Content string `flag:"content" input:"file,stdin"`
|
||||
}
|
||||
|
||||
func TestResolveTypedInputs_File(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
body := "## Title\n\nbody from a file\n"
|
||||
if err := os.WriteFile("body.md", []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
specs, err := walkArgs(reflect.TypeOf(&fileInputArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
rt := newTypedInputRuntime(t, specs, []string{"--content", "@body.md"}, "")
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
t.Fatalf("resolveTypedInputs: %v", err)
|
||||
}
|
||||
out := &fileInputArgs{}
|
||||
if err := bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if out.Content != body {
|
||||
t.Errorf("Content = %q, want file body %q", out.Content, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTypedInputs_Stdin(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{}))
|
||||
rt := newTypedInputRuntime(t, specs, []string{"--content", "-"}, "piped stdin body")
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
t.Fatalf("resolveTypedInputs: %v", err)
|
||||
}
|
||||
out := &fileInputArgs{}
|
||||
_ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if out.Content != "piped stdin body" {
|
||||
t.Errorf("Content = %q, want stdin body", out.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTypedInputs_PlainValueUnchanged(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{}))
|
||||
rt := newTypedInputRuntime(t, specs, []string{"--content", "literal text"}, "")
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
t.Fatalf("resolveTypedInputs: %v", err)
|
||||
}
|
||||
out := &fileInputArgs{}
|
||||
_ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if out.Content != "literal text" {
|
||||
t.Errorf("Content = %q, want unchanged literal", out.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A OneOf variant flag that declares @file/stdin must resolve too — the binder
|
||||
// recurses into buckets because cobra flags are flat regardless of nesting.
|
||||
type nestedInputVariant struct {
|
||||
Body *string `flag:"body" input:"file,stdin"`
|
||||
Raw *string `flag:"raw"`
|
||||
}
|
||||
|
||||
func (nestedInputVariant) OneOf() {}
|
||||
|
||||
type nestedInputArgs struct {
|
||||
Content nestedInputVariant
|
||||
}
|
||||
|
||||
func TestResolveTypedInputs_NestedInOneOf(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
body := "nested variant body\n"
|
||||
if err := os.WriteFile("v.md", []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
specs, err := walkArgs(reflect.TypeOf(&nestedInputArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
rt := newTypedInputRuntime(t, specs, []string{"--body", "@v.md"}, "")
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
t.Fatalf("resolveTypedInputs: %v", err)
|
||||
}
|
||||
out := &nestedInputArgs{}
|
||||
if err := bindBuckets(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindBuckets: %v", err)
|
||||
}
|
||||
if out.Content.Body == nil {
|
||||
t.Fatal("Content.Body is nil — variant not bound")
|
||||
}
|
||||
if *out.Content.Body != body {
|
||||
t.Errorf("Content.Body = %q, want file body %q", *out.Content.Body, body)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mount-time validation: enum / input only on string leaves ------------
|
||||
|
||||
type enumOnIntArgs struct {
|
||||
Level int `flag:"level" enum:"1,2,3"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_EnumOnNonStringErrors(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&enumOnIntArgs{}))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for enum on int field")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "enum tag is only supported on string") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type inputOnBoolArgs struct {
|
||||
Flag bool `flag:"flag" input:"file"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_InputOnNonStringErrors(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&inputOnBoolArgs{}))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for input on bool field")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "input tag is only supported on string") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type unknownInputSrcArgs struct {
|
||||
Content string `flag:"content" input:"bogus"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_UnknownInputSource(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&unknownInputSrcArgs{}))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown input source")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown input source") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A string-alias enum field (e.g. an argstype primitive) must be accepted.
|
||||
type enumOnStringArgs struct {
|
||||
Priority string `flag:"priority" enum:"low,normal,high"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_EnumOnStringOK(t *testing.T) {
|
||||
specs, err := walkArgs(reflect.TypeOf(&enumOnStringArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(specs) != 1 || len(specs[0].EnumValues) != 3 {
|
||||
t.Errorf("enum not parsed: %+v", specs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- enum shell completion + help candidate rendering ---------------------
|
||||
|
||||
func TestRegisterLeaf_EnumCompletion(t *testing.T) {
|
||||
prev := cmdutil.FlagCompletionsEnabled()
|
||||
cmdutil.SetFlagCompletionsEnabled(true)
|
||||
defer cmdutil.SetFlagCompletionsEnabled(prev)
|
||||
|
||||
specs, _ := walkArgs(reflect.TypeOf(&enumOnStringArgs{}))
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
fn, ok := cmd.GetFlagCompletionFunc("priority")
|
||||
if !ok || fn == nil {
|
||||
t.Fatal("expected a completion func registered for --priority")
|
||||
}
|
||||
vals, _ := fn(cmd, nil, "")
|
||||
want := map[string]bool{"low": true, "normal": true, "high": true}
|
||||
if len(vals) != 3 {
|
||||
t.Fatalf("completion candidates = %v, want low/normal/high", vals)
|
||||
}
|
||||
for _, v := range vals {
|
||||
if !want[v] {
|
||||
t.Errorf("unexpected completion candidate %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLeafLine_EnumCandidates(t *testing.T) {
|
||||
s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "normal", "high"}}
|
||||
line := formatLeafLine(" ", s)
|
||||
if !strings.Contains(line, "(one of: low|normal|high)") {
|
||||
t.Errorf("help line missing enum candidates: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLeafLine_EnumAndDefault(t *testing.T) {
|
||||
s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "high"}, DefaultValue: "low"}
|
||||
line := formatLeafLine(" ", s)
|
||||
if !strings.Contains(line, "(one of: low|high)") || !strings.Contains(line, `(default "low")`) {
|
||||
t.Errorf("help line missing enum or default: %q", line)
|
||||
}
|
||||
}
|
||||
95
shortcuts/common/binder_namedslice_nilexec_test.go
Normal file
95
shortcuts/common/binder_namedslice_nilexec_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// --- []<named string> and named slice types bind without panicking --------
|
||||
// Regression guard: reflect.Convert([]string -> []myID) panics, so the binder
|
||||
// must build the slice element-by-element via SetString instead.
|
||||
|
||||
type myID string
|
||||
|
||||
type namedElemArgs struct {
|
||||
Xs []myID `flag:"xs"`
|
||||
}
|
||||
|
||||
func TestSliceFlag_NamedElementType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, err := walkArgs(reflect.TypeOf(&namedElemArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--xs", "a,b"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out := &namedElemArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bind: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out.Xs, []myID{"a", "b"}) {
|
||||
t.Errorf("Xs = %#v, want []myID{a b}", out.Xs)
|
||||
}
|
||||
}
|
||||
|
||||
type myIDList []string
|
||||
|
||||
type namedSliceArgs struct {
|
||||
Ids myIDList `flag:"ids"`
|
||||
}
|
||||
|
||||
func TestSliceFlag_NamedSliceType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&namedSliceArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--ids", "x,y,z"})
|
||||
out := &namedSliceArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bind: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out.Ids, myIDList{"x", "y", "z"}) {
|
||||
t.Errorf("Ids = %#v, want myIDList{x y z}", out.Ids)
|
||||
}
|
||||
}
|
||||
|
||||
// --- nil Execute → not mounted (parity with legacy Shortcut) ---------------
|
||||
|
||||
type nilExecArgs struct {
|
||||
Name string `flag:"name"`
|
||||
}
|
||||
|
||||
func TestMountTyped_NilExecuteNotMounted(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*nilExecArgs]{
|
||||
Service: "x", Command: "+noexec", AuthTypes: []string{"user"}, Risk: "read",
|
||||
// Execute intentionally nil — legacy skips mounting such shortcuts.
|
||||
}
|
||||
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
|
||||
if sub, _, _ := root.Find([]string{"+noexec"}); sub != nil && sub.Name() == "+noexec" {
|
||||
t.Error("nil-Execute typed shortcut must NOT be mounted (parity with legacy)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountTyped_WithExecuteMounted(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*nilExecArgs]{
|
||||
Service: "x", Command: "+yesexec", AuthTypes: []string{"user"}, Risk: "read",
|
||||
Execute: func(ctx context.Context, args *nilExecArgs, rt *RuntimeContext) error { return nil },
|
||||
}
|
||||
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
|
||||
if sub, _, _ := root.Find([]string{"+yesexec"}); sub == nil || sub.Name() != "+yesexec" {
|
||||
t.Error("typed shortcut with Execute should be mounted")
|
||||
}
|
||||
}
|
||||
192
shortcuts/common/binder_slice_hidden_test.go
Normal file
192
shortcuts/common/binder_slice_hidden_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// --- multi-value ([]string) flags -----------------------------------------
|
||||
|
||||
type sliceArgs struct {
|
||||
Ids []string `flag:"ids"` // default → StringSlice (comma-split)
|
||||
Tags []string `flag:"tags" split:"none"` // StringArray (repeatable, no split)
|
||||
}
|
||||
|
||||
func TestSliceFlag_StringSliceDefault(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, err := walkArgs(reflect.TypeOf(&sliceArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--ids", "a,b,c"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out := &sliceArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bind: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out.Ids, []string{"a", "b", "c"}) {
|
||||
t.Errorf("Ids = %#v, want [a b c] (comma-split)", out.Ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSliceFlag_StringArrayNoSplit(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&sliceArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
// repeated; a value containing a comma must NOT be split (StringArray)
|
||||
if err := cmd.ParseFlags([]string{"--tags", "a,b", "--tags", "c"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out := &sliceArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if !reflect.DeepEqual(out.Tags, []string{"a,b", "c"}) {
|
||||
t.Errorf("Tags = %#v, want [\"a,b\" \"c\"] (no split, repeatable)", out.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSliceFlag_UnsetIsEmpty(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&sliceArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &sliceArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if len(out.Ids) != 0 {
|
||||
t.Errorf("Ids = %#v, want empty when unset", out.Ids)
|
||||
}
|
||||
}
|
||||
|
||||
type sliceGroup struct {
|
||||
Items []string `flag:"items"`
|
||||
Note string `flag:"note"`
|
||||
}
|
||||
|
||||
type groupSliceArgs struct {
|
||||
G sliceGroup
|
||||
}
|
||||
|
||||
func TestSliceFlag_InGroup(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, err := walkArgs(reflect.TypeOf(&groupSliceArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
_ = registerFlags(cmd, specs)
|
||||
if err := cmd.ParseFlags([]string{"--items", "x,y", "--note", "hi"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out := &groupSliceArgs{}
|
||||
if err := bindGroups(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out.G.Items, []string{"x", "y"}) {
|
||||
t.Errorf("G.Items = %#v, want [x y]", out.G.Items)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mount-time validation for slices / split -----------------------------
|
||||
|
||||
type splitOnStringArgs struct {
|
||||
S string `flag:"s" split:"none"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_SplitOnNonSliceErrors(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&splitOnStringArgs{}))
|
||||
if err == nil || !strings.Contains(err.Error(), "split tag is only supported on []string") {
|
||||
t.Fatalf("expected split-on-non-slice error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type intSliceArgs struct {
|
||||
N []int `flag:"n"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_NonStringSliceErrors(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&intSliceArgs{}))
|
||||
if err == nil || !strings.Contains(err.Error(), "only []string slices are supported") {
|
||||
t.Fatalf("expected []int error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type unknownSplitArgs struct {
|
||||
S []string `flag:"s" split:"bogus"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_UnknownSplitMode(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&unknownSplitArgs{}))
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown split mode") {
|
||||
t.Fatalf("expected unknown split mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- per-flag hidden ------------------------------------------------------
|
||||
|
||||
type hiddenArgs struct {
|
||||
Visible string `flag:"visible"`
|
||||
Secret string `flag:"secret" hidden:"true"`
|
||||
}
|
||||
|
||||
func TestHiddenFlag_RegisteredButHidden(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&hiddenArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
f := cmd.Flags().Lookup("secret")
|
||||
if f == nil {
|
||||
t.Fatal("secret flag not registered")
|
||||
}
|
||||
if !f.Hidden {
|
||||
t.Error("secret flag should be marked hidden")
|
||||
}
|
||||
// hidden does not mean disabled — it still binds.
|
||||
_ = cmd.ParseFlags([]string{"--secret", "shh", "--visible", "v"})
|
||||
out := &hiddenArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if out.Secret != "shh" {
|
||||
t.Errorf("Secret = %q, want shh (hidden but functional)", out.Secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenFlag_SkippedInHelp(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&hiddenArgs{}))
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
_ = registerFlags(cmd, specs)
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
buildTypedHelp(specs, nil)(cmd, nil)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "--visible") {
|
||||
t.Errorf("help should show --visible:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "--secret") {
|
||||
t.Errorf("help must NOT show hidden --secret:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- @file / stdin help hint ----------------------------------------------
|
||||
|
||||
func TestFormatLeafLine_InputHintBoth(t *testing.T) {
|
||||
s := fieldSpec{FlagName: "content", Description: "the content", Input: []string{File, Stdin}}
|
||||
line := formatLeafLine(" ", s)
|
||||
if !strings.Contains(line, "(supports @file, - for stdin)") {
|
||||
t.Errorf("missing input hint: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLeafLine_InputHintFileOnly(t *testing.T) {
|
||||
s := fieldSpec{FlagName: "content", Description: "c", Input: []string{File}}
|
||||
line := formatLeafLine(" ", s)
|
||||
if !strings.Contains(line, "(supports @file)") || strings.Contains(line, "stdin") {
|
||||
t.Errorf("file-only hint wrong: %q", line)
|
||||
}
|
||||
}
|
||||
312
shortcuts/common/binder_test.go
Normal file
312
shortcuts/common/binder_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
type simpleArgs struct {
|
||||
Name string `flag:"name" desc:"a name"`
|
||||
Count int `flag:"count" default:"3"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_Simple(t *testing.T) {
|
||||
specs, err := walkArgs(reflect.TypeOf(&simpleArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("expected 2 field specs, got %d", len(specs))
|
||||
}
|
||||
if specs[0].FlagName != "name" || specs[1].FlagName != "count" {
|
||||
t.Errorf("flag names: %+v", specs)
|
||||
}
|
||||
}
|
||||
|
||||
type dupTagArgs struct {
|
||||
A string `flag:"x"`
|
||||
B string `flag:"x"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_DuplicateTagPanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("expected panic for duplicate flag tag")
|
||||
}
|
||||
}()
|
||||
_, _ = walkArgs(reflect.TypeOf(&dupTagArgs{}))
|
||||
}
|
||||
|
||||
type bindArgs struct {
|
||||
Name string `flag:"name"`
|
||||
Count int `flag:"count" default:"7"`
|
||||
}
|
||||
|
||||
func TestRegisterAndBind_StringInt(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bindArgs{}))
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
_ = cmd.ParseFlags([]string{"--name", "alice", "--count", "12"})
|
||||
out := &bindArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if out.Name != "alice" {
|
||||
t.Errorf("Name = %q, want alice", out.Name)
|
||||
}
|
||||
if out.Count != 12 {
|
||||
t.Errorf("Count = %d, want 12", out.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_DefaultApplies(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bindArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &bindArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if out.Count != 7 {
|
||||
t.Errorf("default not applied: Count = %d, want 7", out.Count)
|
||||
}
|
||||
}
|
||||
|
||||
type withValidatable struct {
|
||||
ID dummyValidatable `flag:"id"`
|
||||
}
|
||||
|
||||
func TestRunValidateValue_CallsValidatable(t *testing.T) {
|
||||
v := &withValidatable{}
|
||||
specs, _ := walkArgs(reflect.TypeOf(v))
|
||||
rt := &RuntimeContext{}
|
||||
if err := runValidateValue(rt, reflect.ValueOf(v).Elem(), specs); err != nil {
|
||||
t.Errorf("runValidateValue: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type oneOfArgs struct {
|
||||
A *string `flag:"a"`
|
||||
B *string `flag:"b"`
|
||||
}
|
||||
|
||||
func (oneOfArgs) OneOf() {}
|
||||
|
||||
type bucketArgs struct {
|
||||
Bucket oneOfArgs
|
||||
}
|
||||
|
||||
func TestFrameworkRules_OneOfMissing(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &bucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected oneof_missing error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutOneOfMissing {
|
||||
t.Errorf("Subtype = %q", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkRules_OneOfMultiple(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--a", "1", "--b", "2"})
|
||||
out := &bucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected oneof_multiple error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutOneOfMultiple {
|
||||
t.Errorf("Subtype = %q", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// --- top-level group binding (bindGroups) ---
|
||||
|
||||
type dateRange struct {
|
||||
From string `flag:"from"`
|
||||
To string `flag:"to"`
|
||||
}
|
||||
type groupValueArgs struct {
|
||||
Range dateRange
|
||||
}
|
||||
|
||||
func TestBindGroups_TopLevelValueGroup(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&groupValueArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--from", "2026-01-01", "--to", "2026-12-31"})
|
||||
out := &groupValueArgs{}
|
||||
argsVal := reflect.ValueOf(out).Elem()
|
||||
_ = bindFlags(cmd, argsVal, specs)
|
||||
if err := bindGroups(cmd, argsVal, specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if out.Range.From != "2026-01-01" || out.Range.To != "2026-12-31" {
|
||||
t.Errorf("Range = %+v", out.Range)
|
||||
}
|
||||
}
|
||||
|
||||
type defaultedGroup struct {
|
||||
Port string `flag:"port" default:"8080"`
|
||||
Host string `flag:"host" default:"localhost"`
|
||||
}
|
||||
type groupDefaultArgs struct {
|
||||
Conf defaultedGroup
|
||||
}
|
||||
|
||||
func TestBindGroups_TopLevelValueGroup_AppliesDefaults(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&groupDefaultArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &groupDefaultArgs{}
|
||||
argsVal := reflect.ValueOf(out).Elem()
|
||||
_ = bindFlags(cmd, argsVal, specs)
|
||||
if err := bindGroups(cmd, argsVal, specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if out.Conf.Port != "8080" || out.Conf.Host != "localhost" {
|
||||
t.Errorf("defaults not applied: Conf = %+v", out.Conf)
|
||||
}
|
||||
}
|
||||
|
||||
type proxyConf struct {
|
||||
Host string `flag:"proxy-host"`
|
||||
Port string `flag:"proxy-port"`
|
||||
}
|
||||
type groupPtrArgs struct {
|
||||
Proxy *proxyConf
|
||||
}
|
||||
|
||||
func TestBindGroups_TopLevelPtrGroup_AllocatedWhenChanged(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&groupPtrArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--proxy-host", "p.example.com"})
|
||||
out := &groupPtrArgs{}
|
||||
argsVal := reflect.ValueOf(out).Elem()
|
||||
_ = bindFlags(cmd, argsVal, specs)
|
||||
if err := bindGroups(cmd, argsVal, specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if out.Proxy == nil {
|
||||
t.Fatal("expected Proxy to be allocated when an inner flag was changed")
|
||||
}
|
||||
if out.Proxy.Host != "p.example.com" {
|
||||
t.Errorf("Proxy.Host = %q", out.Proxy.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindGroups_TopLevelPtrGroup_NilWhenAbsent(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&groupPtrArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &groupPtrArgs{}
|
||||
argsVal := reflect.ValueOf(out).Elem()
|
||||
_ = bindFlags(cmd, argsVal, specs)
|
||||
if err := bindGroups(cmd, argsVal, specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if out.Proxy != nil {
|
||||
t.Errorf("expected Proxy nil when no inner flag set, got %+v", out.Proxy)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OneOf with a nested group variant (no oneof_trigger; any inner flag
|
||||
// counts as attempting that variant) ---
|
||||
|
||||
type vidGroup struct {
|
||||
File string `flag:"vid-file"`
|
||||
Cover string `flag:"vid-cover"`
|
||||
}
|
||||
type contentBucket struct {
|
||||
Text *string `flag:"ct"`
|
||||
Video *vidGroup
|
||||
}
|
||||
|
||||
func (contentBucket) OneOf() {}
|
||||
|
||||
type contentBucketArgs struct {
|
||||
Bucket contentBucket
|
||||
}
|
||||
|
||||
func TestCheckOneOf_GroupCompanionAloneTriggersVariant(t *testing.T) {
|
||||
// Companion --vid-cover alone (no --vid-file) should count as attempting
|
||||
// the Video variant; OneOf check passes (1 variant attempted) and the
|
||||
// group completeness check then surfaces shortcut_group_incomplete with
|
||||
// the specific missing flag, not a misleading shortcut_oneof_missing.
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--vid-cover", "c.png"})
|
||||
out := &contentBucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected group_incomplete error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutGroupIncomplete {
|
||||
t.Errorf("Subtype = %q, want shortcut_group_incomplete", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOneOf_GroupVariantBothFieldsOK(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--vid-file", "v.mp4", "--vid-cover", "c.png"})
|
||||
out := &contentBucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Errorf("expected OK, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOneOf_SimpleAndGroupBothAttempted_Multiple(t *testing.T) {
|
||||
// Text variant set AND Video group's companion set → both variants are
|
||||
// attempted; expect shortcut_oneof_multiple.
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--ct", "hi", "--vid-cover", "c.png"})
|
||||
out := &contentBucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected oneof_multiple error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutOneOfMultiple {
|
||||
t.Errorf("Subtype = %q, want shortcut_oneof_multiple", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func mustValidationError(t *testing.T, err error) *errs.ValidationError {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
@@ -164,9 +164,6 @@ func CheckApiError(w io.Writer, result interface{}, action string) bool {
|
||||
}
|
||||
|
||||
// HandleApiResult checks for network/API errors and returns the "data" field.
|
||||
//
|
||||
// Deprecated: use RuntimeContext.CallAPITyped (or ClassifyAPIResponse for
|
||||
// self-driven requests) for typed error envelopes.
|
||||
func HandleApiResult(result interface{}, err error, action string) (map[string]interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
|
||||
|
||||
76
shortcuts/common/protocol.go
Normal file
76
shortcuts/common/protocol.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ShortcutDescriptor exposes the read-only metadata that auth, scope-hint,
|
||||
// shortcuts.json generation, and diagnose-scope consumers need. Both legacy
|
||||
// Shortcut and the new TypedShortcut[T] satisfy it (see types.go and
|
||||
// typed_shortcut.go for the implementations).
|
||||
type ShortcutDescriptor interface {
|
||||
GetService() string
|
||||
GetCommand() string
|
||||
GetDescription() string
|
||||
GetAuthTypes() []string
|
||||
GetRisk() string
|
||||
ScopesForIdentity(identity string) []string
|
||||
ConditionalScopesForIdentity(identity string) []string
|
||||
DeclaredScopesForIdentity(identity string) []string
|
||||
}
|
||||
|
||||
// Mountable is the registration contract for register.go. ShortcutDescriptor
|
||||
// is embedded so a single interface slice covers both metadata reads and
|
||||
// cobra mounting.
|
||||
type Mountable interface {
|
||||
ShortcutDescriptor
|
||||
MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory)
|
||||
}
|
||||
|
||||
// OneOfMarker is the opt-in marker for "exactly one variant" sub-structs.
|
||||
// Variant fields must be pointers; the binder treats a non-nil pointer as
|
||||
// "this variant was selected by user-set trigger flag". See spec §"OneOf
|
||||
// trigger semantics" for the trigger rule.
|
||||
type OneOfMarker interface {
|
||||
OneOf()
|
||||
}
|
||||
|
||||
// Validatable is implemented by typed primitives (and may be by sub-structs
|
||||
// that validate a composite value, e.g. a raw JSON body) that own their
|
||||
// format check. The binder calls
|
||||
// ValidateValue per field after Normalize. Returning an error must produce
|
||||
// a *errs.ValidationError so the stderr envelope carries type/subtype/param.
|
||||
type Validatable interface {
|
||||
ValidateValue(rt *RuntimeContext, flagName string) error
|
||||
}
|
||||
|
||||
// Normalizable[T] is implemented by typed primitives that canonicalize raw
|
||||
// user input (e.g. SpreadsheetRef extracting token from URL). The binder
|
||||
// calls Normalize before ValidateValue and writes the canonical value back.
|
||||
// hints, if any, are written to stderr once during the Validate phase.
|
||||
//
|
||||
// Local-path Normalize MUST NOT emit absolute paths in hints (log safety).
|
||||
type Normalizable[T any] interface {
|
||||
Normalize(ctx context.Context, raw string) (value T, hints []string, err error)
|
||||
}
|
||||
|
||||
// ArgsValidator is the cross-field escape hatch. An Args struct may opt in
|
||||
// by adding this method; the binder calls it after framework-derived
|
||||
// validation (required / enum / OneOf / group), before the user-defined
|
||||
// TypedShortcut.Validate hook.
|
||||
type ArgsValidator interface {
|
||||
Validate(ctx context.Context, rt *RuntimeContext) error
|
||||
}
|
||||
|
||||
// HelpExample appears in TypedShortcut.Examples and is rendered by
|
||||
// typed_help under an "EXAMPLES:" section.
|
||||
type HelpExample struct {
|
||||
Title string
|
||||
Cmd string
|
||||
}
|
||||
53
shortcuts/common/protocol_test.go
Normal file
53
shortcuts/common/protocol_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// dummyOneOf demonstrates how a sub-struct opts into OneOfMarker by adding
|
||||
// the empty OneOf() method.
|
||||
type dummyOneOf struct{ A, B *string }
|
||||
|
||||
func (dummyOneOf) OneOf() {}
|
||||
|
||||
func TestOneOfMarker_Detection(t *testing.T) {
|
||||
var v interface{} = dummyOneOf{}
|
||||
if _, ok := v.(OneOfMarker); !ok {
|
||||
t.Fatal("dummyOneOf should satisfy OneOfMarker")
|
||||
}
|
||||
}
|
||||
|
||||
// dummyValidatable implements Validatable.
|
||||
type dummyValidatable struct{}
|
||||
|
||||
func (dummyValidatable) ValidateValue(rt *RuntimeContext, flag string) error { return nil }
|
||||
|
||||
func TestValidatable_InterfaceShape(t *testing.T) {
|
||||
var _ Validatable = dummyValidatable{}
|
||||
}
|
||||
|
||||
// dummyNormalizable implements Normalizable[string].
|
||||
type dummyNormalizable struct{}
|
||||
|
||||
func (dummyNormalizable) Normalize(ctx context.Context, raw string) (string, []string, error) {
|
||||
return raw, nil, nil
|
||||
}
|
||||
|
||||
func TestNormalizable_InterfaceShape(t *testing.T) {
|
||||
var n Normalizable[string] = dummyNormalizable{}
|
||||
got, hints, err := n.Normalize(context.Background(), "x")
|
||||
if err != nil || got != "x" || hints != nil {
|
||||
t.Errorf("dummy Normalize round-trip: got=%q hints=%v err=%v", got, hints, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpExample_Fields(t *testing.T) {
|
||||
ex := HelpExample{Title: "send text", Cmd: "--chat-id oc_x --text hi"}
|
||||
if ex.Title != "send text" || ex.Cmd != "--chat-id oc_x --text hi" {
|
||||
t.Errorf("HelpExample: got %+v", ex)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// RuntimeContext provides helpers for shortcut execution.
|
||||
@@ -48,6 +47,7 @@ type RuntimeContext struct {
|
||||
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
typedArgs any // per-run typed Args, set by binder; consumed by DryRun/Execute
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
@@ -73,16 +73,6 @@ func (ctx *RuntimeContext) IsBot() bool {
|
||||
return ctx.As().IsBot()
|
||||
}
|
||||
|
||||
// Command returns the shortcut command name as cobra knows it (e.g.
|
||||
// "+pivot-create"). Used by per-service helpers (e.g. sheets schema
|
||||
// validation) that key off the shortcut identity.
|
||||
func (ctx *RuntimeContext) Command() string {
|
||||
if ctx.Cmd == nil {
|
||||
return ""
|
||||
}
|
||||
return ctx.Cmd.Name()
|
||||
}
|
||||
|
||||
// UserOpenId returns the current user's open_id from config.
|
||||
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
|
||||
|
||||
@@ -211,12 +201,6 @@ func (ctx *RuntimeContext) Int(name string) int {
|
||||
return v
|
||||
}
|
||||
|
||||
// Float64 returns a float64 flag value (non-integer numbers).
|
||||
func (ctx *RuntimeContext) Float64(name string) float64 {
|
||||
v, _ := ctx.Cmd.Flags().GetFloat64(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
|
||||
func (ctx *RuntimeContext) StrArray(name string) []string {
|
||||
v, _ := ctx.Cmd.Flags().GetStringArray(name)
|
||||
@@ -642,8 +626,6 @@ func WrapOpenError(err error, pathMsg, readMsg string) error {
|
||||
// - Other errors → readMsg prefix (default "cannot read file")
|
||||
//
|
||||
// Pass an optional readMsg to override the non-path-validation message prefix.
|
||||
//
|
||||
// Deprecated: use WrapInputStatErrorTyped for typed error envelopes.
|
||||
func WrapInputStatError(err error, readMsg ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -658,28 +640,9 @@ func WrapInputStatError(err error, readMsg ...string) error {
|
||||
return output.ErrValidation("%s: %s", msg, err)
|
||||
}
|
||||
|
||||
// WrapInputStatErrorTyped wraps a FileIO.Stat/Open error for input file validation.
|
||||
func WrapInputStatErrorTyped(err error, readMsg ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).
|
||||
WithCause(err)
|
||||
}
|
||||
msg := "cannot read file"
|
||||
if len(readMsg) > 0 && readMsg[0] != "" {
|
||||
msg = readMsg[0]
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
|
||||
// using standardized messages and the given error category (e.g. "api_error", "io").
|
||||
// Path validation errors always use ErrValidation (exit code 2).
|
||||
//
|
||||
// Deprecated: use WrapSaveErrorTyped for typed error envelopes.
|
||||
func WrapSaveErrorByCategory(err error, category string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -695,28 +658,6 @@ func WrapSaveErrorByCategory(err error, category string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// WrapSaveErrorTyped maps a FileIO.Save error to typed validation/internal errors.
|
||||
// Unlike WrapSaveErrorByCategory, non-path failures always emit the canonical
|
||||
// "internal" wire type: call sites migrating from a custom category
|
||||
// (e.g. "io", "api_error") change their envelope's type field.
|
||||
func WrapSaveErrorTyped(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var me *fileio.MkdirError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
|
||||
WithCause(err)
|
||||
case errors.As(err, &me):
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).
|
||||
WithCause(err)
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).
|
||||
WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePath checks that path is a valid relative input path within the
|
||||
// working directory by delegating to FileIO.Stat. Returns nil if the path is
|
||||
// valid or does not exist yet; returns an error only for illegal paths
|
||||
@@ -955,29 +896,6 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
return runShortcut(cmd, f, &shortcut, botOnly)
|
||||
},
|
||||
}
|
||||
if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil {
|
||||
onInvoke := shortcut.OnInvoke
|
||||
relaxRequiredForSchema := shortcut.PrintFlagSchema != nil
|
||||
// PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses:
|
||||
// - OnInvoke: fire a side effect (e.g. a deprecation notice) that must
|
||||
// surface even when the call later fails on a missing required flag.
|
||||
// - --print-schema: pure local introspection; relax the required-flag
|
||||
// gate so callers don't fill in unrelated flags just to ask for a
|
||||
// schema (clearing the annotation here is the supported opt-out).
|
||||
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
|
||||
if onInvoke != nil {
|
||||
onInvoke()
|
||||
}
|
||||
if relaxRequiredForSchema {
|
||||
if want, _ := c.Flags().GetBool("print-schema"); want {
|
||||
c.Flags().VisitAll(func(fl *pflag.Flag) {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
|
||||
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
|
||||
cmdutil.SetTips(cmd, shortcut.Tips)
|
||||
@@ -991,31 +909,6 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
// runShortcut is the execution pipeline for a declarative shortcut.
|
||||
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
|
||||
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
|
||||
// --print-schema short-circuits everything below: it's pure local
|
||||
// introspection, no identity / scope / network needed. The flag is
|
||||
// only registered when the shortcut opts in via PrintFlagSchema.
|
||||
if s.PrintFlagSchema != nil {
|
||||
if want, _ := cmd.Flags().GetBool("print-schema"); want {
|
||||
flagName, _ := cmd.Flags().GetString("flag-name")
|
||||
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
|
||||
if err != nil {
|
||||
// PrintFlagSchema implementations return bare errors; wrap as a
|
||||
// structured ExitError so --print-schema (an agent-facing
|
||||
// introspection path) yields a parseable envelope, not a plain
|
||||
// string.
|
||||
if _, ok := err.(*output.ExitError); !ok {
|
||||
err = output.Errorf(output.ExitValidation, "print_schema_error", "%s", err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.Out, string(out))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
as, err := resolveShortcutIdentity(cmd, f, s)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1120,84 +1013,74 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
return rctx, nil
|
||||
}
|
||||
|
||||
// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
|
||||
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
|
||||
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
|
||||
// head of a JSON payload makes json.Unmarshal fail with "invalid character 'ï'".
|
||||
// Some editors and exporters add it silently. Only a leading BOM is removed; interior
|
||||
// occurrences are left untouched.
|
||||
func stripUTF8BOM(s string) string {
|
||||
return strings.TrimPrefix(s, "\uFEFF")
|
||||
}
|
||||
|
||||
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
||||
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
stdinUsed := false
|
||||
for _, fl := range flags {
|
||||
if len(fl.Input) == 0 {
|
||||
continue
|
||||
if err := resolveInputForFlag(rctx, fl.Name, fl.Input, &stdinUsed); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveInputForFlag applies @file / stdin / @@-escape resolution to a single
|
||||
// string flag. sources lists the accepted inputs (File / Stdin); empty sources
|
||||
// or an empty/plain flag value is a no-op. stdinUsed is shared across all flags
|
||||
// of one invocation so stdin (-) is consumed by at most one flag. Both the
|
||||
// legacy resolveInputFlags loop and the typed binder (resolveTypedInputs) call
|
||||
// through here, so @file / stdin behaves identically on TypedShortcut[T].
|
||||
func resolveInputForFlag(rctx *RuntimeContext, name string, sources []string, stdinUsed *bool) error {
|
||||
if len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw, err := rctx.Cmd.Flags().GetString(name)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: Input is only supported for string flags", name)
|
||||
}
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// stdin: -
|
||||
if raw == "-" {
|
||||
if !slices.Contains(sources, Stdin) {
|
||||
return FlagErrorf("--%s does not support stdin (-)", name)
|
||||
}
|
||||
if *stdinUsed {
|
||||
return FlagErrorf("--%s: stdin (-) can only be used by one flag", name)
|
||||
}
|
||||
*stdinUsed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
if err != nil {
|
||||
return ValidationErrorf("--%s: Input is only supported for string flags", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
if raw == "" {
|
||||
continue
|
||||
return FlagErrorf("--%s: failed to read from stdin: %v", name, err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(name, string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// stdin: -
|
||||
if raw == "-" {
|
||||
if !slices.Contains(fl.Input, Stdin) {
|
||||
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
if stdinUsed {
|
||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
stdinUsed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
if err != nil {
|
||||
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
|
||||
WithParam("--" + fl.Name).
|
||||
WithCause(err)
|
||||
}
|
||||
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
|
||||
// cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
continue
|
||||
}
|
||||
// escape: @@ → literal @
|
||||
if strings.HasPrefix(raw, "@@") {
|
||||
rctx.Cmd.Flags().Set(name, raw[1:]) // strip first @
|
||||
return nil
|
||||
}
|
||||
|
||||
// escape: @@ → literal @
|
||||
if strings.HasPrefix(raw, "@@") {
|
||||
rctx.Cmd.Flags().Set(fl.Name, raw[1:]) // strip first @
|
||||
continue
|
||||
// file: @path
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
if !slices.Contains(sources, File) {
|
||||
return FlagErrorf("--%s does not support file input (@path)", name)
|
||||
}
|
||||
|
||||
// file: @path
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
if !slices.Contains(fl.Input, File) {
|
||||
return ValidationErrorf("--%s does not support file input (@path)", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return ValidationErrorf("--%s: file path cannot be empty after @", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return ValidationErrorf("--%s: %v", fl.Name, err).
|
||||
WithParam("--" + fl.Name).
|
||||
WithCause(err)
|
||||
}
|
||||
// strip a leading UTF-8 BOM so it
|
||||
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
continue
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return FlagErrorf("--%s: file path cannot be empty after @", name)
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: %v", name, err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(name, string(data))
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1219,8 +1102,7 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return ValidationErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", ")).
|
||||
WithParam("--" + fl.Name)
|
||||
return FlagErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -1228,8 +1110,7 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
|
||||
func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
|
||||
if s.DryRun == nil {
|
||||
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
|
||||
WithParam("--dry-run")
|
||||
return FlagErrorf("--dry-run is not supported for %s %s", s.Service, s.Command)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "=== Dry Run ===")
|
||||
dryResult := s.DryRun(rctx.ctx, rctx)
|
||||
@@ -1282,10 +1163,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
var d int
|
||||
fmt.Sscanf(fl.Default, "%d", &d)
|
||||
cmd.Flags().Int(fl.Name, d, desc)
|
||||
case "float64":
|
||||
var d float64
|
||||
fmt.Sscanf(fl.Default, "%g", &d)
|
||||
cmd.Flags().Float64(fl.Name, d, desc)
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(fl.Name, nil, desc)
|
||||
case "string_slice":
|
||||
@@ -1313,24 +1190,19 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
}
|
||||
}
|
||||
if s.Risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
if s.PrintFlagSchema != nil {
|
||||
// Guard against a shortcut that already declares these reserved
|
||||
// introspection flags: pflag panics on a duplicate registration.
|
||||
// Mirrors the Lookup guard on --format above.
|
||||
if cmd.Flags().Lookup("print-schema") == nil {
|
||||
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
|
||||
}
|
||||
if cmd.Flags().Lookup("flag-name") == nil {
|
||||
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
}
|
||||
|
||||
// TypedArgs returns the per-run Args struct populated by the binder. Returns
|
||||
// nil for runs that didn't go through TypedShortcut[T]. Callers should
|
||||
// type-assert to the concrete *Args type.
|
||||
func (ctx *RuntimeContext) TypedArgs() any { return ctx.typedArgs }
|
||||
|
||||
// SetTypedArgs stores the per-run Args struct. Called once by the binder
|
||||
// after bind + Normalize complete. Must not be called by user hooks.
|
||||
func (ctx *RuntimeContext) SetTypedArgs(v any) { ctx.typedArgs = v }
|
||||
|
||||
@@ -56,3 +56,17 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
|
||||
t.Fatalf("expected no error for empty args, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContext_TypedArgs_RoundTrip(t *testing.T) {
|
||||
type sample struct{ X int }
|
||||
rt := &RuntimeContext{}
|
||||
if got := rt.TypedArgs(); got != nil {
|
||||
t.Fatalf("fresh RuntimeContext.TypedArgs() = %v, want nil", got)
|
||||
}
|
||||
in := &sample{X: 42}
|
||||
rt.SetTypedArgs(in)
|
||||
out, ok := rt.TypedArgs().(*sample)
|
||||
if !ok || out != in {
|
||||
t.Errorf("round-trip failed: got %v ok=%v", rt.TypedArgs(), ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,116 +96,3 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
|
||||
t.Fatal("did not expect completion func for --format when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutMount_ReservedIntrospectionFlagCollision verifies the reserved
|
||||
// --print-schema / --flag-name flags are registered defensively: a shortcut
|
||||
// that already declares same-named flags must not trigger pflag's duplicate-
|
||||
// registration panic (the Lookup guard in registerShortcutFlagsWithContext).
|
||||
func TestShortcutMount_ReservedIntrospectionFlagCollision(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+introspect",
|
||||
Description: "x",
|
||||
// The shortcut's own flags collide with the names the runner auto-
|
||||
// injects when PrintFlagSchema is set. Without the guard, pflag panics.
|
||||
Flags: []Flag{
|
||||
{Name: "print-schema", Desc: "user-defined collision"},
|
||||
{Name: "flag-name", Desc: "user-defined collision"},
|
||||
},
|
||||
PrintFlagSchema: func(string) ([]byte, error) { return nil, nil },
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Mount panicked on a reserved-flag name collision (Lookup guard missing?): %v", r)
|
||||
}
|
||||
}()
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+introspect"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if cmd.Flags().Lookup("print-schema") == nil {
|
||||
t.Error("print-schema flag should still exist after the guarded registration")
|
||||
}
|
||||
if cmd.Flags().Lookup("flag-name") == nil {
|
||||
t.Error("flag-name flag should still exist after the guarded registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_JsonFlag_AcceptedWhenHasFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+read",
|
||||
Description: "test read",
|
||||
HasFormat: true,
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+read"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if flag := cmd.Flags().Lookup("json"); flag == nil {
|
||||
t.Fatal("expected --json flag to be registered on HasFormat shortcut")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_JsonFlag_SkippedWhenConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+update",
|
||||
Description: "test update",
|
||||
HasFormat: true,
|
||||
Flags: []Flag{
|
||||
{Name: "json", Desc: "body JSON object", Required: true},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+update"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
// --json flag exists (from custom Flags), but should be the string type, not bool.
|
||||
flag := cmd.Flags().Lookup("json")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --json flag from custom Flags")
|
||||
}
|
||||
if flag.DefValue != "" {
|
||||
t.Errorf("expected empty default (string flag), got %q", flag.DefValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_JsonFlag_RegisteredWithoutHasFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+write",
|
||||
Description: "test write",
|
||||
HasFormat: false,
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+write"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
// --format is now registered for all shortcuts (regardless of HasFormat),
|
||||
// so --json should also be present.
|
||||
if flag := cmd.Flags().Lookup("json"); flag == nil {
|
||||
t.Fatal("expected --json flag to be registered even when HasFormat is false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,6 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stdin not supported")
|
||||
}
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support stdin") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -143,7 +142,6 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file not supported")
|
||||
}
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support file input") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -160,7 +158,6 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -174,7 +171,6 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty file path")
|
||||
}
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -216,58 +212,7 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate stdin usage")
|
||||
}
|
||||
assertValidationParam(t, err, "--b")
|
||||
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripUTF8BOM(t *testing.T) {
|
||||
cases := []struct{ name, in, want string }{
|
||||
{"leading BOM removed", "\uFEFFhello", "hello"},
|
||||
{"no BOM unchanged", "hello", "hello"},
|
||||
{"empty unchanged", "", ""},
|
||||
{"only BOM becomes empty", "\uFEFF", ""},
|
||||
{"interior BOM preserved", "a\uFEFFb", "a\uFEFFb"},
|
||||
{"only the first BOM removed", "\uFEFF\uFEFFx", "\uFEFFx"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := stripUTF8BOM(c.in); got != c.want {
|
||||
t.Errorf("%s: stripUTF8BOM(%q) = %q, want %q", c.name, c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_StripBOMStdin(t *testing.T) {
|
||||
// A CSV piped via stdin with a leading BOM (e.g. from an upstream export)
|
||||
// must reach the shortcut without the BOM, so it can't corrupt the first cell.
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "-"}, "\uFEFFname,age\nzhang,8")
|
||||
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
|
||||
|
||||
if err := resolveInputFlags(rctx, flags); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := rctx.Str("csv"); got != "name,age\nzhang,8" {
|
||||
t.Errorf("leading BOM not stripped from stdin, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_StripBOMFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
// A JSON operations file saved with a BOM would otherwise fail json.Unmarshal
|
||||
// with "invalid character 'ï'".
|
||||
if err := os.WriteFile("ops.json", []byte("\uFEFF[{\"shortcut\":\"+cells-set\"}]"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"operations": "@ops.json"}, "")
|
||||
flags := []Flag{{Name: "operations", Input: []string{File, Stdin}}}
|
||||
|
||||
if err := resolveInputFlags(rctx, flags); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := rctx.Str("operations"); got != "[{\"shortcut\":\"+cells-set\"}]" {
|
||||
t.Errorf("leading BOM not stripped from file, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEnumFlags_ReturnsTypedValidation(t *testing.T) {
|
||||
rctx := newTestRuntime(map[string]string{"mode": "delete"})
|
||||
err := validateEnumFlags(rctx, []Flag{
|
||||
{Name: "mode", Enum: []string{"append", "overwrite"}},
|
||||
})
|
||||
assertValidationParam(t, err, "--mode")
|
||||
}
|
||||
|
||||
func TestHandleShortcutDryRunUnsupported_ReturnsTypedValidation(t *testing.T) {
|
||||
err := handleShortcutDryRun(nil, nil, &Shortcut{
|
||||
Service: "doc",
|
||||
Command: "fetch",
|
||||
})
|
||||
assertValidationParam(t, err, "--dry-run")
|
||||
}
|
||||
229
shortcuts/common/typed_help.go
Normal file
229
shortcuts/common/typed_help.go
Normal file
@@ -0,0 +1,229 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// buildTypedHelp returns a cobra.HelpFunc that renders typed shortcuts in
|
||||
// sections (CHOOSE ONE <FIELD> / OPTIONAL / EXAMPLES / GLOBAL FLAGS / Risk: /
|
||||
// Tips:). Cobra's default HelpFunc is preserved for all non-typed commands;
|
||||
// we only override per-command via cmd.SetHelpFunc.
|
||||
//
|
||||
// Section titles use the Args struct's field name (e.g. "TARGET", "CONTENT"),
|
||||
// not the inner Go type name behind the field, so the help mirrors the
|
||||
// user-visible variable name rather than an implementation detail.
|
||||
func buildTypedHelp(specs []fieldSpec, examples []HelpExample) func(*cobra.Command, []string) {
|
||||
return func(cmd *cobra.Command, _ []string) {
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintf(w, "%s — %s\n\n", cmd.Use, cmd.Short)
|
||||
rendered := map[string]struct{}{}
|
||||
renderOneOfSections(w, specs, rendered)
|
||||
renderRequiredSection(w, specs, rendered)
|
||||
renderOptionalSection(w, specs, rendered)
|
||||
renderExamples(w, examples)
|
||||
renderGlobalFlags(w, cmd, rendered)
|
||||
if r, ok := cmdutil.GetRisk(cmd); ok && r != "" {
|
||||
fmt.Fprintf(w, "Risk: %s\n\n", r)
|
||||
}
|
||||
for _, tip := range cmdutil.GetTips(cmd) {
|
||||
fmt.Fprintf(w, "Tips: %s\n", tip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatLeafLine renders one leaf flag line — "--name description" with an
|
||||
// optional `(default "x")` suffix when the field declares a default. Reused by
|
||||
// every section renderer so REQUIRED / OPTIONAL / CHOOSE ONE all surface the
|
||||
// same information density as cobra's legacy default help.
|
||||
func formatLeafLine(indent string, s fieldSpec) string {
|
||||
line := fmt.Sprintf("%s--%s %s", indent, s.FlagName, s.Description)
|
||||
if len(s.EnumValues) > 0 {
|
||||
line += fmt.Sprintf(" (one of: %s)", strings.Join(s.EnumValues, "|"))
|
||||
}
|
||||
if len(s.Input) > 0 {
|
||||
var srcs []string
|
||||
for _, src := range s.Input {
|
||||
switch src {
|
||||
case File:
|
||||
srcs = append(srcs, "@file")
|
||||
case Stdin:
|
||||
srcs = append(srcs, "- for stdin")
|
||||
}
|
||||
}
|
||||
line += fmt.Sprintf(" (supports %s)", strings.Join(srcs, ", "))
|
||||
}
|
||||
if s.DefaultValue != "" {
|
||||
line += fmt.Sprintf(" (default %q)", s.DefaultValue)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// renderOneOfSections walks each OneOf bucket and prints "CHOOSE ONE <FIELD>:"
|
||||
// followed by every flag inside the bucket — including flags inside nested
|
||||
// groups (a paired group's companion flag under its trigger) and nested
|
||||
// raw-content variants (a raw-JSON variant's body + msg-type flags).
|
||||
func renderOneOfSections(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
|
||||
for _, s := range specs {
|
||||
if !s.IsOneOfBkt {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "CHOOSE ONE %s:\n", strings.ToUpper(s.GoFieldName))
|
||||
inner, _ := walkArgs(reflect.PointerTo(s.StructType))
|
||||
renderFlagsInBucket(w, inner, " ", rendered)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
// renderFlagsInBucket renders bucket inner flags, recursing into nested group
|
||||
// / OneOf sub-structs. The structure is FLATTENED — every leaf flag of an
|
||||
// inner variant renders at the parent's indent. The framework treats any
|
||||
// inner flag of a group variant equally (providing any of them counts as
|
||||
// selecting that variant), so the help no longer visually distinguishes a
|
||||
// "trigger" from a "companion".
|
||||
func renderFlagsInBucket(w io.Writer, specs []fieldSpec, indent string, rendered map[string]struct{}) {
|
||||
for _, child := range specs {
|
||||
if child.IsGroup || child.IsOneOfBkt {
|
||||
inner, _ := walkArgs(reflect.PointerTo(child.StructType))
|
||||
for _, leaf := range inner {
|
||||
if leaf.IsGroup || leaf.IsOneOfBkt {
|
||||
grand, _ := walkArgs(reflect.PointerTo(leaf.StructType))
|
||||
renderFlagsInBucket(w, grand, indent+" ", rendered)
|
||||
continue
|
||||
}
|
||||
if leaf.FlagName == "" || leaf.Hidden {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(w, formatLeafLine(indent, leaf))
|
||||
rendered[leaf.FlagName] = struct{}{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if child.FlagName == "" || child.Hidden {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(w, formatLeafLine(indent, child))
|
||||
rendered[child.FlagName] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// renderRequiredSection prints top-level leaf flags that carry the `required`
|
||||
// tag under a "REQUIRED:" header so users can see at a glance which flags they
|
||||
// must supply. Sub-struct fields are skipped here because they're surfaced
|
||||
// under the CHOOSE ONE sections above.
|
||||
func renderRequiredSection(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
|
||||
anyFlag := false
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" || !s.Required || s.Hidden {
|
||||
continue
|
||||
}
|
||||
if !anyFlag {
|
||||
fmt.Fprintln(w, "REQUIRED:")
|
||||
anyFlag = true
|
||||
}
|
||||
fmt.Fprintln(w, formatLeafLine(" ", s))
|
||||
rendered[s.FlagName] = struct{}{}
|
||||
}
|
||||
if anyFlag {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
// renderOptionalSection prints top-level leaf flags that don't belong to any
|
||||
// OneOf bucket and aren't tagged required (those are handled by
|
||||
// renderRequiredSection above). Sub-struct fields are skipped here because
|
||||
// they live under CHOOSE ONE sections above.
|
||||
func renderOptionalSection(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
|
||||
anyFlag := false
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" || s.Required || s.Hidden {
|
||||
continue
|
||||
}
|
||||
if !anyFlag {
|
||||
fmt.Fprintln(w, "OPTIONAL:")
|
||||
anyFlag = true
|
||||
}
|
||||
fmt.Fprintln(w, formatLeafLine(" ", s))
|
||||
rendered[s.FlagName] = struct{}{}
|
||||
}
|
||||
if anyFlag {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func renderExamples(w io.Writer, examples []HelpExample) {
|
||||
if len(examples) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "EXAMPLES:")
|
||||
for _, e := range examples {
|
||||
fmt.Fprintf(w, " %-20s %s\n", e.Title+":", e.Cmd)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// renderGlobalFlags prints framework-injected and cobra-inherited flags
|
||||
// (--as / --dry-run / --jq / --format / -h / --help) that the typed Args
|
||||
// struct does NOT define. The `rendered` set captures every flag name we
|
||||
// already emitted under CHOOSE ONE / OPTIONAL — anything else surfaced by
|
||||
// cmd.Flags() or cmd.InheritedFlags() falls under GLOBAL FLAGS.
|
||||
func renderGlobalFlags(w io.Writer, cmd *cobra.Command, rendered map[string]struct{}) {
|
||||
type globalFlag struct {
|
||||
Name string
|
||||
Shorthand string
|
||||
Usage string
|
||||
}
|
||||
var globals []globalFlag
|
||||
seen := map[string]bool{}
|
||||
collect := func(fs *pflag.FlagSet) {
|
||||
fs.VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden {
|
||||
return
|
||||
}
|
||||
if _, alreadyRendered := rendered[f.Name]; alreadyRendered {
|
||||
return
|
||||
}
|
||||
if seen[f.Name] {
|
||||
return
|
||||
}
|
||||
seen[f.Name] = true
|
||||
globals = append(globals, globalFlag{Name: f.Name, Shorthand: f.Shorthand, Usage: f.Usage})
|
||||
})
|
||||
}
|
||||
collect(cmd.Flags())
|
||||
collect(cmd.InheritedFlags())
|
||||
// Cobra auto-injects --help on every command, but it does not appear in
|
||||
// LocalFlags or InheritedFlags until the command has been resolved at
|
||||
// invocation time. Ensure it is always documented.
|
||||
if !seen["help"] {
|
||||
globals = append(globals, globalFlag{Name: "help", Shorthand: "h", Usage: "show help"})
|
||||
}
|
||||
if len(globals) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "GLOBAL FLAGS:")
|
||||
for _, g := range globals {
|
||||
if g.Shorthand != "" {
|
||||
fmt.Fprintf(w, " -%s, --%s %s\n", g.Shorthand, g.Name, g.Usage)
|
||||
} else {
|
||||
fmt.Fprintf(w, " --%s %s\n", g.Name, g.Usage)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
108
shortcuts/common/typed_help_test.go
Normal file
108
shortcuts/common/typed_help_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
type helpDemoTarget struct {
|
||||
Chat *string `flag:"chat-id"`
|
||||
User *string `flag:"user-id"`
|
||||
}
|
||||
|
||||
func (helpDemoTarget) OneOf() {}
|
||||
|
||||
type helpDemoArgs struct {
|
||||
Target helpDemoTarget
|
||||
Idemp string `flag:"idempotency-key"`
|
||||
}
|
||||
|
||||
func TestTypedHelp_RendersSections(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cmd := &cobra.Command{Use: "+demo", Short: "demo command"}
|
||||
cmdutil.SetRisk(cmd, "high-risk-write")
|
||||
cmdutil.SetTips(cmd, []string{"call carefully"})
|
||||
specs, err := walkArgs(reflect.TypeOf(&helpDemoArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
cmd.SetHelpFunc(buildTypedHelp(specs, []HelpExample{{Title: "demo", Cmd: "--chat-id oc_x"}}))
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.HelpFunc()(cmd, nil)
|
||||
out := buf.String()
|
||||
for _, section := range []string{"CHOOSE ONE", "OPTIONAL", "EXAMPLES", "Risk:", "Tips:"} {
|
||||
if !strings.Contains(out, section) {
|
||||
t.Errorf("help missing %q section; got:\n%s", section, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// helpReqDefaultsArgs covers two cases the renderer previously botched:
|
||||
//
|
||||
// 1. A required flag (--limit) should land under "REQUIRED:" instead of
|
||||
// being silently glued into "OPTIONAL:".
|
||||
// 2. A flag with default (--page-size) should display (default "20").
|
||||
type helpReqDefaultsArgs struct {
|
||||
Limit string `flag:"limit" required:"true" desc:"max items"`
|
||||
PageSize string `flag:"page-size" default:"20" desc:"page size"`
|
||||
Verbose string `flag:"verbose" desc:"output mode"`
|
||||
}
|
||||
|
||||
func TestTypedHelp_RequiredSectionAndDefaults(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "+demo", Short: "demo command"}
|
||||
specs, err := walkArgs(reflect.TypeOf(&helpReqDefaultsArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
cmd.SetHelpFunc(buildTypedHelp(specs, nil))
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.HelpFunc()(cmd, nil)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "REQUIRED:") {
|
||||
t.Errorf("expected REQUIRED: section, got:\n%s", out)
|
||||
}
|
||||
// --limit must be under REQUIRED, NOT under OPTIONAL.
|
||||
reqIdx := strings.Index(out, "REQUIRED:")
|
||||
optIdx := strings.Index(out, "OPTIONAL:")
|
||||
limitIdx := strings.Index(out, "--limit")
|
||||
if reqIdx < 0 || optIdx < 0 || limitIdx < 0 {
|
||||
t.Fatalf("layout markers missing: REQUIRED@%d OPTIONAL@%d --limit@%d\n%s", reqIdx, optIdx, limitIdx, out)
|
||||
}
|
||||
if !(reqIdx < limitIdx && limitIdx < optIdx) {
|
||||
t.Errorf("--limit should appear under REQUIRED before OPTIONAL; got:\n%s", out)
|
||||
}
|
||||
|
||||
// Default value must be surfaced for --page-size.
|
||||
if !strings.Contains(out, `(default "20")`) {
|
||||
t.Errorf("expected default value rendering for --page-size; got:\n%s", out)
|
||||
}
|
||||
|
||||
// Plain flag without default or required tag must NOT carry a (default …) suffix.
|
||||
verboseLine := ""
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, "--verbose") {
|
||||
verboseLine = line
|
||||
break
|
||||
}
|
||||
}
|
||||
if verboseLine == "" {
|
||||
t.Fatalf("expected --verbose flag to be rendered; got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(verboseLine, "(default") {
|
||||
t.Errorf("--verbose has no default, should not carry default suffix: %q", verboseLine)
|
||||
}
|
||||
}
|
||||
231
shortcuts/common/typed_shortcut.go
Normal file
231
shortcuts/common/typed_shortcut.go
Normal file
@@ -0,0 +1,231 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TypedShortcut is the generic counterpart of the legacy Shortcut struct.
|
||||
// Args must be a pointer to a struct (e.g. *MyArgs). Mounting reflects the
|
||||
// struct, registers cobra flags, then delegates to a synthesized Shortcut
|
||||
// shell so the existing run pipeline (identity / scopes / @file / stdin /
|
||||
// jq / dry-run / high-risk gate) is reused verbatim.
|
||||
type TypedShortcut[T any] struct {
|
||||
Service, Command, Description string
|
||||
Risk string
|
||||
Scopes, UserScopes, BotScopes []string
|
||||
ConditionalScopes []string
|
||||
ConditionalUserScopes []string
|
||||
ConditionalBotScopes []string
|
||||
AuthTypes []string
|
||||
HasFormat bool
|
||||
Tips []string
|
||||
Hidden bool
|
||||
|
||||
Examples []HelpExample
|
||||
|
||||
DryRun func(ctx context.Context, args T, rt *RuntimeContext) *DryRunAPI
|
||||
Validate func(ctx context.Context, args T, rt *RuntimeContext) error
|
||||
Execute func(ctx context.Context, args T, rt *RuntimeContext) error
|
||||
|
||||
PostMount func(cmd *cobra.Command)
|
||||
}
|
||||
|
||||
func (s TypedShortcut[T]) GetService() string { return s.Service }
|
||||
func (s TypedShortcut[T]) GetCommand() string { return s.Command }
|
||||
func (s TypedShortcut[T]) GetDescription() string { return s.Description }
|
||||
func (s TypedShortcut[T]) GetAuthTypes() []string { return s.AuthTypes }
|
||||
func (s TypedShortcut[T]) GetRisk() string { return s.Risk }
|
||||
|
||||
func (s TypedShortcut[T]) ScopesForIdentity(identity string) []string {
|
||||
switch identity {
|
||||
case "user":
|
||||
if len(s.UserScopes) > 0 {
|
||||
return s.UserScopes
|
||||
}
|
||||
case "bot":
|
||||
if len(s.BotScopes) > 0 {
|
||||
return s.BotScopes
|
||||
}
|
||||
}
|
||||
return s.Scopes
|
||||
}
|
||||
|
||||
func (s TypedShortcut[T]) ConditionalScopesForIdentity(identity string) []string {
|
||||
switch identity {
|
||||
case "user":
|
||||
if len(s.ConditionalUserScopes) > 0 {
|
||||
return s.ConditionalUserScopes
|
||||
}
|
||||
case "bot":
|
||||
if len(s.ConditionalBotScopes) > 0 {
|
||||
return s.ConditionalBotScopes
|
||||
}
|
||||
}
|
||||
return s.ConditionalScopes
|
||||
}
|
||||
|
||||
func (s TypedShortcut[T]) DeclaredScopesForIdentity(identity string) []string {
|
||||
base := s.ScopesForIdentity(identity)
|
||||
extra := s.ConditionalScopesForIdentity(identity)
|
||||
if len(base) == 0 && len(extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(base)+len(extra))
|
||||
seen := map[string]struct{}{}
|
||||
for _, scope := range append(base, extra...) {
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
out = append(out, scope)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Mount registers the typed shortcut on a parent command, mirroring the legacy
|
||||
// Shortcut.Mount API so migrating common.Shortcut → common.TypedShortcut[T]
|
||||
// does not force existing callers (or their tests) to also rewrite the Mount
|
||||
// call site. Delegates to MountWithContext with a background context.
|
||||
func (s TypedShortcut[T]) Mount(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
s.MountWithContext(context.Background(), parent, f)
|
||||
}
|
||||
|
||||
// MountWithContext is implemented in Task 16's adapter section.
|
||||
func (s TypedShortcut[T]) MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
mountTyped[T](ctx, parent, f, s)
|
||||
}
|
||||
|
||||
// mountTyped synthesizes a legacy *Shortcut shell that delegates back to the
|
||||
// typed hooks. The shell's Validate/DryRun/Execute closures read or write
|
||||
// the per-run typed args via RuntimeContext.{TypedArgs,SetTypedArgs}.
|
||||
//
|
||||
// Pipeline order inside the shell (matches runShortcut at runner.go:748):
|
||||
//
|
||||
// 1. identity / scopes / runtime — handled by Shortcut.runShortcut
|
||||
// 2. validateEnumFlags — Shortcut machinery
|
||||
// 3. resolveInputFlags — @file / stdin (legacy shell only; the
|
||||
// synthesized shell's Flags slice is empty, so this is a no-op for typed —
|
||||
// typed flags declare inputs via the `input` tag, resolved in step 5)
|
||||
// 4. ValidateJqFlags — --jq
|
||||
// 5. shell.Validate — resolveTypedInputs (@file / stdin for
|
||||
// `input`-tagged flags), binds T, runs Normalize / ValidateValue /
|
||||
// framework rules / ArgsValidator / user-typed Validate
|
||||
// 6. --dry-run gate — shell.DryRun reads typed args from rt
|
||||
// 7. high-risk-write confirmation — when Risk == "high-risk-write"
|
||||
// 8. shell.Execute — reads typed args from rt
|
||||
func mountTyped[T any](ctx context.Context, parent *cobra.Command, f *cmdutil.Factory, s TypedShortcut[T]) {
|
||||
// Mirror legacy Shortcut.MountWithContext: a shortcut with no Execute is
|
||||
// not a runnable command, so it is not mounted at all (rather than mounted
|
||||
// and erroring at invocation time). Keeps the command tree identical to
|
||||
// legacy after migration.
|
||||
if s.Execute == nil {
|
||||
return
|
||||
}
|
||||
var zero T
|
||||
argsType := reflect.TypeOf(zero)
|
||||
if argsType == nil || argsType.Kind() != reflect.Ptr {
|
||||
panic("TypedShortcut[T]: T must be a pointer to a struct, got " + fmt.Sprintf("%T", zero))
|
||||
}
|
||||
specs, err := walkArgs(argsType)
|
||||
if err != nil {
|
||||
panic("TypedShortcut[T].Mount: " + err.Error())
|
||||
}
|
||||
|
||||
shell := Shortcut{
|
||||
Service: s.Service,
|
||||
Command: s.Command,
|
||||
Description: s.Description,
|
||||
Risk: s.Risk,
|
||||
Scopes: s.Scopes,
|
||||
UserScopes: s.UserScopes,
|
||||
BotScopes: s.BotScopes,
|
||||
ConditionalScopes: s.ConditionalScopes,
|
||||
ConditionalUserScopes: s.ConditionalUserScopes,
|
||||
ConditionalBotScopes: s.ConditionalBotScopes,
|
||||
AuthTypes: s.AuthTypes,
|
||||
HasFormat: s.HasFormat,
|
||||
Tips: s.Tips,
|
||||
Hidden: s.Hidden,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
panic("TypedShortcut[T] registerFlags: " + err.Error())
|
||||
}
|
||||
cmd.SetHelpFunc(buildTypedHelp(specs, s.Examples))
|
||||
if s.PostMount != nil {
|
||||
s.PostMount(cmd)
|
||||
}
|
||||
},
|
||||
Validate: func(c context.Context, rt *RuntimeContext) error {
|
||||
// @file / stdin resolution for flags that declared an `input` tag.
|
||||
// Runs before bindFlags so the resolved file/stdin content is what
|
||||
// gets bound into the Args struct.
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
args := reflect.New(argsType.Elem()).Interface().(T)
|
||||
argsVal := reflect.ValueOf(args).Elem()
|
||||
if err := bindFlags(rt.Cmd, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bindBuckets(rt.Cmd, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bindGroups(rt.Cmd, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runNormalize(c, rt, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runValidateValue(rt, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runFrameworkRules(rt.Cmd, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if av, ok := any(args).(ArgsValidator); ok {
|
||||
if err := av.Validate(c, rt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rt.SetTypedArgs(args)
|
||||
if s.Validate != nil {
|
||||
return s.Validate(c, args, rt)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(c context.Context, rt *RuntimeContext) error {
|
||||
if s.Execute == nil {
|
||||
return &errs.InternalError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Message: "shortcut " + s.Service + " " + s.Command + " has no Execute handler",
|
||||
},
|
||||
}
|
||||
}
|
||||
args, _ := rt.TypedArgs().(T)
|
||||
return s.Execute(c, args, rt)
|
||||
},
|
||||
}
|
||||
if s.DryRun != nil {
|
||||
shell.DryRun = func(c context.Context, rt *RuntimeContext) *DryRunAPI {
|
||||
args, _ := rt.TypedArgs().(T)
|
||||
return s.DryRun(c, args, rt)
|
||||
}
|
||||
}
|
||||
shell.MountWithContext(ctx, parent, f)
|
||||
}
|
||||
179
shortcuts/common/typed_shortcut_test.go
Normal file
179
shortcuts/common/typed_shortcut_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
type stubArgs struct{}
|
||||
|
||||
func newTypedFixture() TypedShortcut[*stubArgs] {
|
||||
return TypedShortcut[*stubArgs]{
|
||||
Service: "im",
|
||||
Command: "+demo",
|
||||
Description: "demo",
|
||||
AuthTypes: []string{"user"},
|
||||
Risk: "write",
|
||||
Scopes: []string{"x"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_Descriptors(t *testing.T) {
|
||||
ts := newTypedFixture()
|
||||
if ts.GetService() != "im" {
|
||||
t.Errorf("GetService=%q", ts.GetService())
|
||||
}
|
||||
if ts.GetCommand() != "+demo" {
|
||||
t.Errorf("GetCommand=%q", ts.GetCommand())
|
||||
}
|
||||
if ts.GetDescription() != "demo" {
|
||||
t.Errorf("GetDescription=%q", ts.GetDescription())
|
||||
}
|
||||
if ts.GetRisk() != "write" {
|
||||
t.Errorf("GetRisk=%q", ts.GetRisk())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_SatisfiesMountable(t *testing.T) {
|
||||
var _ Mountable = newTypedFixture()
|
||||
}
|
||||
|
||||
type adapterArgs struct {
|
||||
Name string `flag:"name"`
|
||||
}
|
||||
|
||||
// TestMountTyped_RegistersFlags verifies the mountTyped adapter wires the
|
||||
// binder-registered flag into cobra. Full Validate/Execute integration is
|
||||
// covered by tests_e2e/shortcuts/ (out of unit-test scope — runShortcut
|
||||
// needs a fully-initialized Factory with auth/config).
|
||||
func TestMountTyped_RegistersFlags(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*adapterArgs]{
|
||||
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
|
||||
Risk: "read",
|
||||
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
|
||||
sub, _, err := root.Find([]string{"+demo"})
|
||||
if err != nil {
|
||||
t.Fatalf("find subcommand: %v", err)
|
||||
}
|
||||
if sub.Flag("name") == nil {
|
||||
t.Error("expected --name flag to be registered via binder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountTyped_HelpFuncInstalled(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*adapterArgs]{
|
||||
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
|
||||
Risk: "read",
|
||||
Examples: []HelpExample{{Title: "demo", Cmd: "--name alice"}},
|
||||
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error { return nil },
|
||||
}
|
||||
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
|
||||
sub, _, _ := root.Find([]string{"+demo"})
|
||||
if sub == nil || sub.HelpFunc() == nil {
|
||||
t.Fatal("expected typed help func installed on subcommand")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypedShortcut_Mount verifies the no-context convenience Mount API still
|
||||
// works after migration, mirroring legacy Shortcut.Mount so existing tests of
|
||||
// migrated shortcuts don't need to switch to MountWithContext.
|
||||
func TestTypedShortcut_Mount(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*adapterArgs]{
|
||||
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
|
||||
Risk: "read",
|
||||
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error { return nil },
|
||||
}
|
||||
ts.Mount(root, &cmdutil.Factory{})
|
||||
sub, _, err := root.Find([]string{"+demo"})
|
||||
if err != nil || sub == nil {
|
||||
t.Fatalf("find subcommand: %v (sub=%v)", err, sub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_GetAuthTypes(t *testing.T) {
|
||||
ts := TypedShortcut[*stubArgs]{AuthTypes: []string{"user", "bot"}}
|
||||
if got := ts.GetAuthTypes(); !slices.Equal(got, []string{"user", "bot"}) {
|
||||
t.Errorf("GetAuthTypes=%v", got)
|
||||
}
|
||||
if got := (TypedShortcut[*stubArgs]{}).GetAuthTypes(); got != nil {
|
||||
t.Errorf("empty GetAuthTypes=%v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_ScopesForIdentity(t *testing.T) {
|
||||
ts := TypedShortcut[*stubArgs]{
|
||||
Scopes: []string{"base"},
|
||||
UserScopes: []string{"u"},
|
||||
BotScopes: []string{"b"},
|
||||
}
|
||||
cases := []struct {
|
||||
identity string
|
||||
want []string
|
||||
}{
|
||||
{"user", []string{"u"}},
|
||||
{"bot", []string{"b"}},
|
||||
{"other", []string{"base"}},
|
||||
{"", []string{"base"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ts.ScopesForIdentity(c.identity); !slices.Equal(got, c.want) {
|
||||
t.Errorf("ScopesForIdentity(%q)=%v, want %v", c.identity, got, c.want)
|
||||
}
|
||||
}
|
||||
// Falls back to Scopes when the identity-specific list is empty.
|
||||
fallback := TypedShortcut[*stubArgs]{Scopes: []string{"base"}}
|
||||
if got := fallback.ScopesForIdentity("user"); !slices.Equal(got, []string{"base"}) {
|
||||
t.Errorf("fallback user=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_ConditionalScopesForIdentity(t *testing.T) {
|
||||
ts := TypedShortcut[*stubArgs]{
|
||||
ConditionalScopes: []string{"cbase"},
|
||||
ConditionalUserScopes: []string{"cu"},
|
||||
ConditionalBotScopes: []string{"cb"},
|
||||
}
|
||||
cases := []struct {
|
||||
identity string
|
||||
want []string
|
||||
}{
|
||||
{"user", []string{"cu"}},
|
||||
{"bot", []string{"cb"}},
|
||||
{"other", []string{"cbase"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ts.ConditionalScopesForIdentity(c.identity); !slices.Equal(got, c.want) {
|
||||
t.Errorf("ConditionalScopesForIdentity(%q)=%v, want %v", c.identity, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_DeclaredScopesForIdentity(t *testing.T) {
|
||||
// Merges base + conditional, dedupes overlap, drops empty strings.
|
||||
ts := TypedShortcut[*stubArgs]{
|
||||
UserScopes: []string{"a", "b", ""},
|
||||
ConditionalUserScopes: []string{"b", "c"},
|
||||
}
|
||||
if got := ts.DeclaredScopesForIdentity("user"); !slices.Equal(got, []string{"a", "b", "c"}) {
|
||||
t.Errorf("merge+dedupe got %v", got)
|
||||
}
|
||||
// Returns nil when nothing is declared on either side.
|
||||
if got := (TypedShortcut[*stubArgs]{}).DeclaredScopesForIdentity("user"); got != nil {
|
||||
t.Errorf("empty got %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
// Flag describes a CLI flag for a shortcut.
|
||||
type Flag struct {
|
||||
Name string // flag name (e.g. "calendar-id")
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
|
||||
Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
@@ -58,29 +58,6 @@ type Shortcut struct {
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
|
||||
|
||||
// OnInvoke, when non-nil, runs from the command's cobra PreRunE — before
|
||||
// cobra validates required flags — so its side effect fires even when the
|
||||
// call later fails on a missing required flag (which short-circuits before
|
||||
// Validate/Execute). The backward-compat aliases use it to record a
|
||||
// deprecation notice that must surface regardless of whether the call
|
||||
// validates. Fire-and-forget: no args, no return (e.g. deprecation.SetPending).
|
||||
OnInvoke func()
|
||||
|
||||
// PrintFlagSchema, when non-nil, opts this shortcut into the
|
||||
// `--print-schema --flag-name <name>` runtime introspection contract.
|
||||
// The framework auto-injects those two system flags and short-circuits
|
||||
// Validate/Execute when --print-schema is set, dispatching to this hook.
|
||||
//
|
||||
// Contract:
|
||||
// - flagName == "" → list the flags this shortcut can describe
|
||||
// (output is impl-defined; agents read this to
|
||||
// discover which flags are introspectable).
|
||||
// - flagName == "...": → return the JSON Schema (or schema-like blob)
|
||||
// for that flag.
|
||||
// Return value is written to stdout verbatim; callers typically format
|
||||
// it as JSON. Returning an error surfaces as a normal command error.
|
||||
PrintFlagSchema func(flagName string) ([]byte, error)
|
||||
|
||||
// PostMount is an optional hook called after the cobra.Command is fully
|
||||
// configured (flags registered, tips set) and after parent.AddCommand(cmd)
|
||||
// has attached it to the parent. Use it to install custom help functions or
|
||||
@@ -148,3 +125,23 @@ func (s *Shortcut) DeclaredScopesForIdentity(identity string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetService returns the parent cobra command name (e.g. "im"). Trivial
|
||||
// accessor so *Shortcut satisfies ShortcutDescriptor alongside the existing
|
||||
// pointer-receiver scope methods.
|
||||
func (s *Shortcut) GetService() string { return s.Service }
|
||||
|
||||
// GetCommand returns the shortcut subcommand name (e.g. "+messages-send").
|
||||
func (s *Shortcut) GetCommand() string { return s.Command }
|
||||
|
||||
// GetDescription returns the short help text.
|
||||
func (s *Shortcut) GetDescription() string { return s.Description }
|
||||
|
||||
// GetAuthTypes returns the supported identities. Defaults to ["user"] is
|
||||
// applied at mount time, not here, to preserve the existing field semantics.
|
||||
func (s *Shortcut) GetAuthTypes() []string { return s.AuthTypes }
|
||||
|
||||
// GetRisk returns the static risk level ("read" / "write" / "high-risk-write").
|
||||
// Empty string defaults to "read" by convention; callers must handle that
|
||||
// case (same convention as the existing cobra annotation logic).
|
||||
func (s *Shortcut) GetRisk() string { return s.Risk }
|
||||
|
||||
@@ -105,3 +105,32 @@ func TestDeclaredScopesForIdentity_ConditionalOnly(t *testing.T) {
|
||||
t.Errorf("expected conditional-only declared scopes, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcut_DescriptorAccessors(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "im",
|
||||
Command: "+messages-send",
|
||||
Description: "Send a message",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Risk: "write",
|
||||
}
|
||||
if s.GetService() != "im" {
|
||||
t.Errorf("GetService = %q", s.GetService())
|
||||
}
|
||||
if s.GetCommand() != "+messages-send" {
|
||||
t.Errorf("GetCommand = %q", s.GetCommand())
|
||||
}
|
||||
if s.GetDescription() != "Send a message" {
|
||||
t.Errorf("GetDescription = %q", s.GetDescription())
|
||||
}
|
||||
if got := s.GetAuthTypes(); len(got) != 2 || got[0] != "user" || got[1] != "bot" {
|
||||
t.Errorf("GetAuthTypes = %v", got)
|
||||
}
|
||||
if s.GetRisk() != "write" {
|
||||
t.Errorf("GetRisk = %q", s.GetRisk())
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcut_SatisfiesShortcutDescriptor(t *testing.T) {
|
||||
var _ ShortcutDescriptor = &Shortcut{}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -14,32 +13,9 @@ import (
|
||||
// open_id, removes duplicates case-insensitively while preserving the
|
||||
// first-occurrence form, and returns nil for an empty input. flagName is
|
||||
// used in error messages to point the user at the offending CLI flag.
|
||||
//
|
||||
// Deprecated: use ResolveOpenIDsTyped for typed error envelopes.
|
||||
func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
|
||||
out, msg := resolveOpenIDs(flagName, ids, runtime)
|
||||
if msg != "" {
|
||||
return nil, output.ErrValidation("%s", msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResolveOpenIDsTyped expands the special identifier "me" to the current
|
||||
// user's open_id, removes duplicates case-insensitively while preserving the
|
||||
// first-occurrence form, and returns nil for an empty input. flagName names
|
||||
// the flag being resolved (e.g. "--user-ids") and is recorded on the typed
|
||||
// error.
|
||||
func ResolveOpenIDsTyped(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
|
||||
out, msg := resolveOpenIDs(flagName, ids, runtime)
|
||||
if msg != "" {
|
||||
return nil, ValidationErrorf("%s", msg).WithParam(flagName)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, string) {
|
||||
if len(ids) == 0 {
|
||||
return nil, ""
|
||||
return nil, nil
|
||||
}
|
||||
currentUserID := runtime.UserOpenId()
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
@@ -47,7 +23,7 @@ func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]s
|
||||
for _, id := range ids {
|
||||
if strings.EqualFold(id, "me") {
|
||||
if currentUserID == "" {
|
||||
return nil, fmt.Sprintf("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
|
||||
return nil, output.ErrValidation("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
|
||||
}
|
||||
id = currentUserID
|
||||
}
|
||||
@@ -58,5 +34,5 @@ func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]s
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, ""
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -75,24 +75,3 @@ func TestResolveOpenIDs_DedupIsCaseInsensitive(t *testing.T) {
|
||||
t.Fatalf("case-insensitive dedup failed: got %v, want [ou_abc123]", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDsTyped_MeWithoutLogin_ReturnsTypedValidation(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("")
|
||||
_, err := ResolveOpenIDsTyped("--user-ids", []string{"me"}, rt)
|
||||
validationErr := assertValidationParam(t, err, "--user-ids")
|
||||
if !strings.Contains(validationErr.Message, "--user-ids") {
|
||||
t.Fatalf("error should mention the offending flag name; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDsTyped_ExpandsMeAndDedups(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("ou_self")
|
||||
out, err := ResolveOpenIDsTyped("--user-ids", []string{"me", "ou_a", "me", "ou_a"}, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"ou_self", "ou_a"}
|
||||
if len(out) != len(want) || out[0] != want[0] || out[1] != want[1] {
|
||||
t.Fatalf("got %v, want %v", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,26 +8,16 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// FlagErrorf returns a validation error with flag context (exit code 2).
|
||||
//
|
||||
// Deprecated: use ValidationErrorf for typed error envelopes.
|
||||
func FlagErrorf(format string, args ...any) error {
|
||||
return output.ErrValidation(format, args...)
|
||||
}
|
||||
|
||||
// ValidationErrorf returns a typed validation error with invalid_argument subtype.
|
||||
func ValidationErrorf(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
// MutuallyExclusive checks that at most one of the given flags is set.
|
||||
//
|
||||
// Deprecated: use MutuallyExclusiveTyped for typed error envelopes.
|
||||
func MutuallyExclusive(rt *RuntimeContext, flags ...string) error {
|
||||
var set []string
|
||||
for _, f := range flags {
|
||||
@@ -42,25 +32,7 @@ func MutuallyExclusive(rt *RuntimeContext, flags ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MutuallyExclusiveTyped checks that at most one of the given flags is set.
|
||||
func MutuallyExclusiveTyped(rt *RuntimeContext, flags ...string) error {
|
||||
var set []string
|
||||
for _, f := range flags {
|
||||
val := rt.Str(f)
|
||||
if val != "" {
|
||||
set = append(set, "--"+f)
|
||||
}
|
||||
}
|
||||
if len(set) > 1 {
|
||||
return ValidationErrorf("%s are mutually exclusive", strings.Join(set, " and ")).
|
||||
WithParams(invalidParams(set, "mutually exclusive")...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AtLeastOne checks that at least one of the given flags is set.
|
||||
//
|
||||
// Deprecated: use AtLeastOneTyped for typed error envelopes.
|
||||
func AtLeastOne(rt *RuntimeContext, flags ...string) error {
|
||||
for _, f := range flags {
|
||||
if rt.Str(f) != "" {
|
||||
@@ -74,24 +46,7 @@ func AtLeastOne(rt *RuntimeContext, flags ...string) error {
|
||||
return FlagErrorf("specify at least one of %s", strings.Join(names, " or "))
|
||||
}
|
||||
|
||||
// AtLeastOneTyped checks that at least one of the given flags is set.
|
||||
func AtLeastOneTyped(rt *RuntimeContext, flags ...string) error {
|
||||
for _, f := range flags {
|
||||
if rt.Str(f) != "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
names := make([]string, len(flags))
|
||||
for i, f := range flags {
|
||||
names[i] = "--" + f
|
||||
}
|
||||
return ValidationErrorf("specify at least one of %s", strings.Join(names, " or ")).
|
||||
WithParams(invalidParams(names, "required; specify at least one")...)
|
||||
}
|
||||
|
||||
// ExactlyOne checks that exactly one of the given flags is set.
|
||||
//
|
||||
// Deprecated: use ExactlyOneTyped for typed error envelopes.
|
||||
func ExactlyOne(rt *RuntimeContext, flags ...string) error {
|
||||
if err := AtLeastOne(rt, flags...); err != nil {
|
||||
return err
|
||||
@@ -99,18 +54,8 @@ func ExactlyOne(rt *RuntimeContext, flags ...string) error {
|
||||
return MutuallyExclusive(rt, flags...)
|
||||
}
|
||||
|
||||
// ExactlyOneTyped checks that exactly one of the given flags is set.
|
||||
func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
|
||||
if err := AtLeastOneTyped(rt, flags...); err != nil {
|
||||
return err
|
||||
}
|
||||
return MutuallyExclusiveTyped(rt, flags...)
|
||||
}
|
||||
|
||||
// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal].
|
||||
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
|
||||
//
|
||||
// Deprecated: use ValidatePageSizeTyped for typed error envelopes.
|
||||
func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
|
||||
s := rt.Str(flagName)
|
||||
if s == "" {
|
||||
@@ -126,25 +71,6 @@ func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, m
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ValidatePageSizeTyped validates that the named flag (if set) is an integer within [minVal, maxVal].
|
||||
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
|
||||
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
|
||||
s := rt.Str(flagName)
|
||||
param := "--" + flagName
|
||||
if s == "" {
|
||||
return defaultVal, nil
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, ValidationErrorf("invalid --%s %q: must be an integer", flagName, s).WithParam(param)
|
||||
}
|
||||
if n < minVal || n > maxVal {
|
||||
return 0, ValidationErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal).
|
||||
WithParam(param)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ParseIntBounded parses an int flag and clamps it to [min, max].
|
||||
func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
|
||||
v := rt.Int(name)
|
||||
@@ -161,26 +87,13 @@ func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
|
||||
// working directory. It catches traversal, symlink escape, and control
|
||||
// characters by delegating to FileIO.ResolvePath. Works for both file and
|
||||
// directory paths.
|
||||
//
|
||||
// Deprecated: use ValidateSafePathTyped for typed error envelopes.
|
||||
func ValidateSafePath(fio fileio.FileIO, path string) error {
|
||||
_, err := fio.ResolvePath(path)
|
||||
return err
|
||||
}
|
||||
|
||||
// ValidateSafePathTyped ensures path resolves within the current working directory.
|
||||
func ValidateSafePathTyped(fio fileio.FileIO, path string) error {
|
||||
_, err := fio.ResolvePath(path)
|
||||
if err != nil {
|
||||
return ValidationErrorf("%s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RejectDangerousChars returns an error if value contains ASCII control
|
||||
// characters or dangerous Unicode code points.
|
||||
//
|
||||
// Deprecated: use RejectDangerousCharsTyped for typed error envelopes.
|
||||
func RejectDangerousChars(paramName, value string) error {
|
||||
for _, r := range value {
|
||||
if r < 0x20 && r != '\t' && r != '\n' {
|
||||
@@ -195,31 +108,3 @@ func RejectDangerousChars(paramName, value string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RejectDangerousCharsTyped returns an error if value contains ASCII control
|
||||
// characters or dangerous Unicode code points.
|
||||
func RejectDangerousCharsTyped(paramName, value string) error {
|
||||
for _, r := range value {
|
||||
if r < 0x20 && r != '\t' && r != '\n' {
|
||||
return ValidationErrorf("parameter %q contains control character U+%04X", paramName, r).
|
||||
WithParam(paramName)
|
||||
}
|
||||
if r == 0x7F {
|
||||
return ValidationErrorf("parameter %q contains DEL character", paramName).
|
||||
WithParam(paramName)
|
||||
}
|
||||
if IsDangerousUnicode(r) {
|
||||
return ValidationErrorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r).
|
||||
WithParam(paramName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidParams(names []string, reason string) []errs.InvalidParam {
|
||||
params := make([]errs.InvalidParam, len(names))
|
||||
for i, name := range names {
|
||||
params[i] = errs.InvalidParam{Name: name, Reason: reason}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -11,31 +11,10 @@ import (
|
||||
|
||||
// ValidateChatID checks if a chat ID has valid format (oc_ prefix).
|
||||
// Also extracts token from URL if provided.
|
||||
//
|
||||
// Deprecated: use ValidateChatIDTyped for typed error envelopes.
|
||||
func ValidateChatID(input string) (string, error) {
|
||||
chatID, msg := normalizeChatID(input)
|
||||
if msg != "" {
|
||||
return "", output.ErrValidation("%s", msg)
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
|
||||
// Also extracts token from URL if provided. param names the flag being
|
||||
// validated (e.g. "--chat-ids") and is recorded on the typed error.
|
||||
func ValidateChatIDTyped(param, input string) (string, error) {
|
||||
chatID, msg := normalizeChatID(input)
|
||||
if msg != "" {
|
||||
return "", ValidationErrorf("%s", msg).WithParam(param)
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
func normalizeChatID(input string) (string, string) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", "chat ID cannot be empty"
|
||||
return "", output.ErrValidation("chat ID cannot be empty")
|
||||
}
|
||||
// Extract from URL if present
|
||||
if strings.Contains(input, "feishu.cn") || strings.Contains(input, "larksuite.com") {
|
||||
@@ -49,40 +28,19 @@ func normalizeChatID(input string) (string, string) {
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(input, "oc_") {
|
||||
return "", "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)"
|
||||
return "", output.ErrValidation("invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)")
|
||||
}
|
||||
return input, ""
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// ValidateUserID checks if a user ID has valid format (ou_ prefix).
|
||||
//
|
||||
// Deprecated: use ValidateUserIDTyped for typed error envelopes.
|
||||
func ValidateUserID(input string) (string, error) {
|
||||
userID, msg := normalizeUserID(input)
|
||||
if msg != "" {
|
||||
return "", output.ErrValidation("%s", msg)
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// ValidateUserIDTyped checks if a user ID has valid format (ou_ prefix).
|
||||
// param names the flag being validated (e.g. "--creator-ids") and is
|
||||
// recorded on the typed error.
|
||||
func ValidateUserIDTyped(param, input string) (string, error) {
|
||||
userID, msg := normalizeUserID(input)
|
||||
if msg != "" {
|
||||
return "", ValidationErrorf("%s", msg).WithParam(param)
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func normalizeUserID(input string) (string, string) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", "user ID cannot be empty"
|
||||
return "", output.ErrValidation("user ID cannot be empty")
|
||||
}
|
||||
if !strings.HasPrefix(input, "ou_") {
|
||||
return "", "invalid user ID format, should start with 'ou_' (e.g., ou_abc123)"
|
||||
return "", output.ErrValidation("invalid user ID format, should start with 'ou_' (e.g., ou_abc123)")
|
||||
}
|
||||
return input, ""
|
||||
return input, nil
|
||||
}
|
||||
|
||||
@@ -4,14 +4,10 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -30,24 +26,6 @@ func newTestRuntime(flags map[string]string) *RuntimeContext {
|
||||
return &RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func assertValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if param != "" && validationErr.Param != param {
|
||||
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func TestMutuallyExclusive(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -91,109 +69,6 @@ func TestMutuallyExclusive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrorf_ReturnsTypedInvalidArgument(t *testing.T) {
|
||||
err := ValidationErrorf("bad %s", "flag")
|
||||
validationErr := assertValidationParam(t, err, "")
|
||||
if validationErr.Message != "bad flag" {
|
||||
t.Fatalf("Message = %q, want %q", validationErr.Message, "bad flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedFlagGroupHelpers_ReturnValidationParams(t *testing.T) {
|
||||
t.Run("mutually exclusive", func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"a": "x", "b": "y"})
|
||||
validationErr := assertValidationParam(t, MutuallyExclusiveTyped(rt, "a", "b"), "")
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
|
||||
}
|
||||
if validationErr.Params[0].Name != "--a" || validationErr.Params[1].Name != "--b" {
|
||||
t.Fatalf("Params names = %+v, want --a/--b", validationErr.Params)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("at least one", func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"a": "", "b": ""})
|
||||
validationErr := assertValidationParam(t, AtLeastOneTyped(rt, "a", "b"), "")
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
|
||||
}
|
||||
if !strings.Contains(validationErr.Message, "--a or --b") {
|
||||
t.Fatalf("Message = %q, want flag group", validationErr.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exactly one", func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"a": "x", "b": "y"})
|
||||
validationErr := assertValidationParam(t, ExactlyOneTyped(rt, "a", "b"), "")
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidatePageSizeTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"page-size": "nope"})
|
||||
_, err := ValidatePageSizeTyped(rt, "page-size", 10, 1, 20)
|
||||
assertValidationParam(t, err, "--page-size")
|
||||
|
||||
rt = newTestRuntime(map[string]string{"page-size": "30"})
|
||||
_, err = ValidatePageSizeTyped(rt, "page-size", 10, 1, 20)
|
||||
assertValidationParam(t, err, "--page-size")
|
||||
}
|
||||
|
||||
func TestValidateIDTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
chatID, err := ValidateChatIDTyped("--chat-ids", "https://example.feishu.cn/foo/oc_abc")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateChatIDTyped valid URL: %v", err)
|
||||
}
|
||||
if chatID != "oc_abc" {
|
||||
t.Fatalf("chatID = %q, want oc_abc", chatID)
|
||||
}
|
||||
assertValidationParam(t, func() error {
|
||||
_, err := ValidateChatIDTyped("--chat-ids", "bad")
|
||||
return err
|
||||
}(), "--chat-ids")
|
||||
assertValidationParam(t, func() error {
|
||||
_, err := ValidateUserIDTyped("--creator-ids", "bad")
|
||||
return err
|
||||
}(), "--creator-ids")
|
||||
}
|
||||
|
||||
func TestRejectDangerousCharsTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
err := RejectDangerousCharsTyped("--query", "bad\x01")
|
||||
validationErr := assertValidationParam(t, err, "--query")
|
||||
if !strings.Contains(validationErr.Message, "control character") {
|
||||
t.Fatalf("Message = %q, want control character", validationErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapInputStatErrorTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
cause := &fileio.PathValidationError{Err: errors.New("outside cwd")}
|
||||
err := WrapInputStatErrorTyped(cause)
|
||||
validationErr := assertValidationParam(t, err, "")
|
||||
if !strings.Contains(validationErr.Message, "unsafe file path") {
|
||||
t.Fatalf("Message = %q, want unsafe file path", validationErr.Message)
|
||||
}
|
||||
if !errors.Is(err, fileio.ErrPathValidation) {
|
||||
t.Fatalf("expected errors.Is(fileio.ErrPathValidation) to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSaveErrorTyped_ClassifiesPathAndFileIO(t *testing.T) {
|
||||
pathErr := &fileio.PathValidationError{Err: errors.New("outside cwd")}
|
||||
assertValidationParam(t, WrapSaveErrorTyped(pathErr), "")
|
||||
|
||||
mkdirErr := &fileio.MkdirError{Err: errors.New("permission denied")}
|
||||
err := WrapSaveErrorTyped(mkdirErr)
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if internalErr.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtLeastOne(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -371,20 +246,3 @@ func TestValidateSafePath_AllowsNonExistentPath(t *testing.T) {
|
||||
t.Fatalf("expected no error for non-existent path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateSafePathTyped_ReturnsTypedValidation verifies that an escaping
|
||||
// path is rejected with a typed validation error and a safe path passes.
|
||||
func TestValidateSafePathTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
outside := t.TempDir()
|
||||
workDir := t.TempDir()
|
||||
chdirForTest(t, workDir)
|
||||
|
||||
if err := os.Symlink(outside, filepath.Join(workDir, "evil_out")); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
assertValidationParam(t, ValidateSafePathTyped(&localfileio.LocalFileIO{}, "evil_out"), "")
|
||||
|
||||
if err := ValidateSafePathTyped(&localfileio.LocalFileIO{}, "new_output_dir"); err != nil {
|
||||
t.Fatalf("expected no error for safe path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@ func parseDriveSearchPageSize(raw string) (int, error) {
|
||||
// server-side failure or empty result.
|
||||
func validateDriveSearchIDs(spec driveSearchSpec) error {
|
||||
for _, id := range spec.CreatorIDs {
|
||||
if _, err := common.ValidateUserIDTyped("--creator-ids", id); err != nil {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids")
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,7 @@ func validateDriveSearchIDs(spec driveSearchSpec) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids")
|
||||
}
|
||||
for _, id := range spec.ChatIDs {
|
||||
if _, err := common.ValidateChatIDTyped("--chat-ids", id); err != nil {
|
||||
if _, err := common.ValidateChatID(id); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids %q: %s", id, err).WithParam("--chat-ids")
|
||||
}
|
||||
}
|
||||
@@ -370,7 +370,7 @@ func validateDriveSearchIDs(spec driveSearchSpec) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n).WithParam("--sharer-ids")
|
||||
}
|
||||
for _, id := range spec.SharerIDs {
|
||||
if _, err := common.ValidateUserIDTyped("--sharer-ids", id); err != nil {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids %q: %s", id, err).WithParam("--sharer-ids")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -197,12 +196,8 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
|
||||
|
||||
header, _ := card["header"].(cardObj)
|
||||
title := ""
|
||||
subtitle := ""
|
||||
headerTags := ""
|
||||
if header != nil {
|
||||
title = c.extractHeaderTitle(header)
|
||||
subtitle = c.extractHeaderSubtitle(header)
|
||||
headerTags = c.extractHeaderTags(header)
|
||||
}
|
||||
|
||||
bodyContent := ""
|
||||
@@ -211,19 +206,13 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
if title != "" && subtitle != "" {
|
||||
sb.WriteString(fmt.Sprintf("<card title=\"%s\" subtitle=\"%s\">\n", cardEscapeAttr(title), cardEscapeAttr(subtitle)))
|
||||
} else if title != "" {
|
||||
sb.WriteString(fmt.Sprintf("<card title=\"%s\">\n", cardEscapeAttr(title)))
|
||||
} else if subtitle != "" {
|
||||
sb.WriteString(fmt.Sprintf("<card subtitle=\"%s\">\n", cardEscapeAttr(subtitle)))
|
||||
if title != "" {
|
||||
sb.WriteString("<card title=\"")
|
||||
sb.WriteString(cardEscapeAttr(title))
|
||||
sb.WriteString("\">\n")
|
||||
} else {
|
||||
sb.WriteString("<card>\n")
|
||||
}
|
||||
if headerTags != "" {
|
||||
sb.WriteString(headerTags)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if bodyContent != "" {
|
||||
sb.WriteString(bodyContent)
|
||||
sb.WriteString("\n")
|
||||
@@ -244,49 +233,6 @@ func (c *cardConverter) extractHeaderTitle(header cardObj) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractHeaderSubtitle returns the subtitle text of a card header, supporting both
|
||||
// the property-wrapped and flat element formats.
|
||||
func (c *cardConverter) extractHeaderSubtitle(header cardObj) string {
|
||||
if prop, ok := header["property"].(cardObj); ok {
|
||||
if subtitleElem, ok := prop["subtitle"]; ok {
|
||||
return c.extractTextContent(subtitleElem)
|
||||
}
|
||||
}
|
||||
if subtitleElem, ok := header["subtitle"]; ok {
|
||||
return c.extractTextContent(subtitleElem)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractHeaderTags returns a space-joined string of header tag labels from textTagList,
|
||||
// supporting both property-wrapped and flat header formats.
|
||||
func (c *cardConverter) extractHeaderTags(header cardObj) string {
|
||||
var prop cardObj
|
||||
if p, ok := header["property"].(cardObj); ok {
|
||||
prop = p
|
||||
} else {
|
||||
prop = header
|
||||
}
|
||||
tagList, ok := prop["textTagList"].([]interface{})
|
||||
if !ok || len(tagList) == 0 {
|
||||
return ""
|
||||
}
|
||||
var tags []string
|
||||
for _, tag := range tagList {
|
||||
tm, ok := tag.(cardObj)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if text := c.convertElement(tm, 0); text != "" {
|
||||
tags = append(tags, text)
|
||||
}
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(tags, " ")
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertBody(body cardObj) string {
|
||||
var elements []interface{}
|
||||
|
||||
@@ -533,11 +479,8 @@ func (c *cardConverter) convertDiv(prop cardObj, _ string) string {
|
||||
|
||||
if textElem, ok := prop["text"].(cardObj); ok {
|
||||
if text := c.convertElement(textElem, 0); text != "" {
|
||||
textProp := c.extractProperty(textElem)
|
||||
if textStyle, ok := textProp["textStyle"].(cardObj); ok {
|
||||
if size, _ := textStyle["size"].(string); size == "notation" {
|
||||
text = "📝 " + text
|
||||
}
|
||||
if textSize, _ := textElem["text_size"].(string); textSize == "notation" {
|
||||
text = "📝 " + text
|
||||
}
|
||||
results = append(results, text)
|
||||
}
|
||||
@@ -615,14 +558,7 @@ func (c *cardConverter) convertEmoji(prop cardObj) string {
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertLocalDatetime(prop cardObj) string {
|
||||
var ms string
|
||||
switch v := prop["milliseconds"].(type) {
|
||||
case string:
|
||||
ms = v
|
||||
case float64:
|
||||
ms = strconv.FormatInt(int64(v), 10)
|
||||
}
|
||||
if ms != "" {
|
||||
if ms, ok := prop["milliseconds"].(string); ok && ms != "" {
|
||||
if formatted := cardFormatMillisToISO8601(ms); formatted != "" {
|
||||
return formatted
|
||||
}
|
||||
@@ -853,22 +789,22 @@ func (c *cardConverter) convertCollapsiblePanel(prop cardObj, _ string) string {
|
||||
}
|
||||
}
|
||||
|
||||
indicator := "▶"
|
||||
if expanded {
|
||||
indicator = "▼"
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(indicator + " " + title + "\n")
|
||||
if elements, ok := prop["elements"].([]interface{}); ok {
|
||||
content := c.convertElements(elements, 1)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if line != "" {
|
||||
sb.WriteString(" " + line + "\n")
|
||||
shouldExpand := expanded || c.mode == cardModeDetailed
|
||||
if shouldExpand {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("▼ " + title + "\n")
|
||||
if elements, ok := prop["elements"].([]interface{}); ok {
|
||||
content := c.convertElements(elements, 1)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if line != "" {
|
||||
sb.WriteString(" " + line + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString("▲")
|
||||
return sb.String()
|
||||
}
|
||||
sb.WriteString("▲")
|
||||
return sb.String()
|
||||
return "▶ " + title
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertInteractiveContainer(prop cardObj, id string) string {
|
||||
@@ -916,17 +852,10 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string {
|
||||
}
|
||||
|
||||
disabled, _ := prop["disabled"].(bool)
|
||||
if disabled {
|
||||
result := fmt.Sprintf("[%s ✗]", buttonText)
|
||||
if tips, ok := prop["disabledTips"].(cardObj); ok {
|
||||
if tipsText := c.extractTextContent(tips); tipsText != "" {
|
||||
result += fmt.Sprintf("(tips:\"%s\")", tipsText)
|
||||
}
|
||||
}
|
||||
return result
|
||||
if disabled && c.mode == cardModeConcise {
|
||||
return fmt.Sprintf("[%s ✗]", buttonText)
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("[%s]", buttonText)
|
||||
if actions, ok := prop["actions"].([]interface{}); ok {
|
||||
for _, action := range actions {
|
||||
am, ok := action.(cardObj)
|
||||
@@ -936,32 +865,24 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string {
|
||||
if am["type"] == "open_url" {
|
||||
if ad, ok := am["action"].(cardObj); ok {
|
||||
if urlStr, ok := ad["url"].(string); ok && urlStr != "" {
|
||||
result = fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr)
|
||||
break
|
||||
return fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if confirmObj, ok := prop["confirm"].(cardObj); ok {
|
||||
var parts []string
|
||||
if titleElem, ok := confirmObj["title"]; ok {
|
||||
if t := c.extractTextContent(titleElem); t != "" {
|
||||
parts = append(parts, t)
|
||||
if disabled && c.mode == cardModeDetailed {
|
||||
result := fmt.Sprintf("[%s ✗]", buttonText)
|
||||
if tips, ok := prop["disabledTips"].(cardObj); ok {
|
||||
if tipsText := c.extractTextContent(tips); tipsText != "" {
|
||||
result += fmt.Sprintf("(tips:\"%s\")", tipsText)
|
||||
}
|
||||
}
|
||||
if textElem, ok := confirmObj["text"]; ok {
|
||||
if t := c.extractTextContent(textElem); t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
result += fmt.Sprintf("(confirm:\"%s\")", strings.Join(parts, ": "))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return result
|
||||
return fmt.Sprintf("[%s]", buttonText)
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertActions(prop cardObj) string {
|
||||
@@ -993,33 +914,11 @@ func (c *cardConverter) convertOverflow(prop cardObj) string {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
text := ""
|
||||
if textElem, ok := om["text"].(cardObj); ok {
|
||||
text = c.extractTextContent(textElem)
|
||||
}
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
urlStr := ""
|
||||
if actions, ok := om["actions"].([]interface{}); ok {
|
||||
for _, a := range actions {
|
||||
am, ok := a.(cardObj)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if am["type"] == "open_url" {
|
||||
if ad, ok := am["action"].(cardObj); ok {
|
||||
urlStr, _ = ad["url"].(string)
|
||||
}
|
||||
}
|
||||
if text := c.extractTextContent(textElem); text != "" {
|
||||
optTexts = append(optTexts, text)
|
||||
}
|
||||
}
|
||||
if urlStr != "" {
|
||||
text = fmt.Sprintf("[%s](%s)", escapeMDLinkText(text), urlStr)
|
||||
} else if value, _ := om["value"].(string); value != "" {
|
||||
text += "(" + value + ")"
|
||||
}
|
||||
optTexts = append(optTexts, text)
|
||||
}
|
||||
return "⋮ " + strings.Join(optTexts, ", ")
|
||||
}
|
||||
@@ -1059,20 +958,17 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value, _ := om["value"].(string)
|
||||
optText := ""
|
||||
if textElem, ok := om["text"].(cardObj); ok {
|
||||
optText = c.extractTextContent(textElem)
|
||||
}
|
||||
if optText == "" {
|
||||
optText = c.lookupOptionUserName(value)
|
||||
}
|
||||
if optText == "" {
|
||||
optText = value
|
||||
optText, _ = om["value"].(string)
|
||||
}
|
||||
if optText == "" {
|
||||
continue
|
||||
}
|
||||
value, _ := om["value"].(string)
|
||||
if selectedValues[value] {
|
||||
optText = "✓" + optText
|
||||
hasSelected = true
|
||||
@@ -1093,15 +989,17 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str
|
||||
}
|
||||
|
||||
result := "{" + strings.Join(optionTexts, " / ") + "}"
|
||||
var attrs []string
|
||||
if isMulti {
|
||||
attrs = append(attrs, "multi")
|
||||
}
|
||||
if c.mode == cardModeDetailed && strings.Contains(id, "person") {
|
||||
attrs = append(attrs, "type:person")
|
||||
}
|
||||
if len(attrs) > 0 {
|
||||
result += "(" + strings.Join(attrs, " ") + ")"
|
||||
if c.mode == cardModeDetailed {
|
||||
var attrs []string
|
||||
if isMulti {
|
||||
attrs = append(attrs, "multi")
|
||||
}
|
||||
if strings.Contains(id, "person") {
|
||||
attrs = append(attrs, "type:person")
|
||||
}
|
||||
if len(attrs) > 0 {
|
||||
result += "(" + strings.Join(attrs, " ") + ")"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1127,17 +1025,6 @@ func (c *cardConverter) convertSelectImg(prop cardObj, _ string) string {
|
||||
}
|
||||
value, _ := om["value"].(string)
|
||||
text := fmt.Sprintf("🖼️ Image %d", i+1)
|
||||
if value != "" {
|
||||
text += "(" + value + ")"
|
||||
}
|
||||
if imageID, ok := om["imageID"].(string); ok && imageID != "" {
|
||||
originKey, imgToken := c.getImageKeyAndToken(imageID)
|
||||
if originKey != "" {
|
||||
text += "(img_key:" + originKey + ")"
|
||||
} else if imgToken != "" {
|
||||
text += "(img_token:" + imgToken + ")"
|
||||
}
|
||||
}
|
||||
if selectedValues[value] {
|
||||
text = "✓" + text
|
||||
}
|
||||
@@ -1240,14 +1127,13 @@ func (c *cardConverter) convertImage(prop cardObj, _ string) string {
|
||||
}
|
||||
|
||||
result := "🖼️ " + alt
|
||||
if imageID, ok := prop["imageID"].(string); ok && imageID != "" {
|
||||
originKey, imgToken := c.getImageKeyAndToken(imageID)
|
||||
if originKey != "" {
|
||||
result += "(img_key:" + originKey + ")"
|
||||
} else if imgToken != "" {
|
||||
result += "(img_token:" + imgToken + ")"
|
||||
} else {
|
||||
result += "(img_key:" + imageID + ")"
|
||||
if c.mode == cardModeDetailed {
|
||||
if imageID, ok := prop["imageID"].(string); ok && imageID != "" {
|
||||
if token := c.getImageToken(imageID); token != "" {
|
||||
result += "(img_token:" + token + ")"
|
||||
} else {
|
||||
result += "(img_key:" + imageID + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -1259,25 +1145,20 @@ func (c *cardConverter) convertImgCombination(prop cardObj) string {
|
||||
return ""
|
||||
}
|
||||
result := fmt.Sprintf("🖼️ %d image(s)", len(imgList))
|
||||
var keys []string
|
||||
for _, img := range imgList {
|
||||
im, ok := img.(cardObj)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if imageID, ok := im["imageID"].(string); ok && imageID != "" {
|
||||
originKey, imgToken := c.getImageKeyAndToken(imageID)
|
||||
if originKey != "" {
|
||||
keys = append(keys, originKey)
|
||||
} else if imgToken != "" {
|
||||
keys = append(keys, imgToken)
|
||||
} else {
|
||||
if c.mode == cardModeDetailed {
|
||||
var keys []string
|
||||
for _, img := range imgList {
|
||||
im, ok := img.(cardObj)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if imageID, ok := im["imageID"].(string); ok && imageID != "" {
|
||||
keys = append(keys, imageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
result += "(keys:" + strings.Join(keys, ",") + ")"
|
||||
if len(keys) > 0 {
|
||||
result += "(keys:" + strings.Join(keys, ",") + ")"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1295,11 +1176,7 @@ func (c *cardConverter) convertChart(prop cardObj, _ string) string {
|
||||
if ct, ok := chartSpec["type"].(string); ok && ct != "" {
|
||||
chartType = ct
|
||||
if typeName, ok := cardChartTypeNames[ct]; ok {
|
||||
if title != "Chart" {
|
||||
title += " (" + typeName + ")"
|
||||
} else {
|
||||
title = typeName
|
||||
}
|
||||
title += typeName
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1317,25 +1194,12 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
// VChart spec: data is an array of series objects ([{"id":"...","values":[...]}]).
|
||||
// Older/object format: data is a map with a "values" key directly.
|
||||
var values []interface{}
|
||||
switch d := chartSpec["data"].(type) {
|
||||
case cardObj:
|
||||
if v, ok := d["values"].([]interface{}); ok {
|
||||
values = v
|
||||
}
|
||||
case []interface{}:
|
||||
for _, series := range d {
|
||||
if sm, ok := series.(cardObj); ok {
|
||||
if v, ok := sm["values"].([]interface{}); ok {
|
||||
values = append(values, v...)
|
||||
}
|
||||
}
|
||||
}
|
||||
dataObj, ok := chartSpec["data"].(cardObj)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if len(values) == 0 {
|
||||
values, ok := dataObj["values"].([]interface{})
|
||||
if !ok || len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1380,24 +1244,28 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri
|
||||
|
||||
func (c *cardConverter) convertAudio(prop cardObj, _ string) string {
|
||||
result := "🎵 Audio"
|
||||
fileID, _ := prop["fileID"].(string)
|
||||
if fileID == "" {
|
||||
fileID, _ = prop["audioID"].(string)
|
||||
}
|
||||
if fileID != "" {
|
||||
result += "(key:" + fileID + ")"
|
||||
if c.mode == cardModeDetailed {
|
||||
fileID, _ := prop["fileID"].(string)
|
||||
if fileID == "" {
|
||||
fileID, _ = prop["audioID"].(string)
|
||||
}
|
||||
if fileID != "" {
|
||||
result += "(key:" + fileID + ")"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertVideo(prop cardObj, _ string) string {
|
||||
result := "🎬 Video"
|
||||
fileID, _ := prop["fileID"].(string)
|
||||
if fileID == "" {
|
||||
fileID, _ = prop["videoID"].(string)
|
||||
}
|
||||
if fileID != "" {
|
||||
result += "(key:" + fileID + ")"
|
||||
if c.mode == cardModeDetailed {
|
||||
fileID, _ := prop["fileID"].(string)
|
||||
if fileID == "" {
|
||||
fileID, _ = prop["videoID"].(string)
|
||||
}
|
||||
if fileID != "" {
|
||||
result += "(key:" + fileID + ")"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1455,14 +1323,9 @@ func (c *cardConverter) convertTable(prop cardObj) string {
|
||||
func (c *cardConverter) extractTableCellValue(data interface{}) string {
|
||||
switch v := data.(type) {
|
||||
case string:
|
||||
// Lark API serialises array-type cell data as a Go-format string like
|
||||
// "[map[text:VIP] map[text:Premium]]". Detect and extract text values.
|
||||
if texts := goMapArrayTexts(v); len(texts) > 0 {
|
||||
return strings.Join(texts, ", ")
|
||||
}
|
||||
return v
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
return strconv.FormatFloat(v, 'f', 2, 64)
|
||||
case []interface{}:
|
||||
var texts []string
|
||||
for _, item := range v {
|
||||
@@ -1483,47 +1346,6 @@ func (c *cardConverter) extractTableCellValue(data interface{}) string {
|
||||
}
|
||||
}
|
||||
|
||||
// goMapNextKey matches the start of the next key in a Go fmt map literal (space + identifier + colon).
|
||||
var goMapNextKey = regexp.MustCompile(` [a-zA-Z_][a-zA-Z0-9_]*:`)
|
||||
|
||||
// goMapArrayTexts extracts "text" values from a Go-format slice-of-maps string,
|
||||
// e.g. "[map[text:VIP] map[text:Premium]]" → ["VIP", "Premium"].
|
||||
// Values may contain spaces; they are delimited by the next map key or by "]".
|
||||
// Returns nil if the string doesn't look like this format.
|
||||
func goMapArrayTexts(s string) []string {
|
||||
if !strings.HasPrefix(s, "[") || !strings.Contains(s, "map[") {
|
||||
return nil
|
||||
}
|
||||
const key = "text:"
|
||||
var texts []string
|
||||
rest := s
|
||||
for {
|
||||
idx := strings.Index(rest, key)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
after := rest[idx+len(key):]
|
||||
bracketEnd := strings.Index(after, "]")
|
||||
nextKey := goMapNextKey.FindStringIndex(after)
|
||||
var end int
|
||||
if nextKey != nil && (bracketEnd < 0 || nextKey[0] < bracketEnd) {
|
||||
end = nextKey[0]
|
||||
} else if bracketEnd >= 0 {
|
||||
end = bracketEnd
|
||||
} else {
|
||||
if after != "" {
|
||||
texts = append(texts, after)
|
||||
}
|
||||
break
|
||||
}
|
||||
if val := after[:end]; val != "" {
|
||||
texts = append(texts, val)
|
||||
}
|
||||
rest = after[end:]
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertPerson(prop cardObj, _ string) string {
|
||||
userID, _ := prop["userID"].(string)
|
||||
if userID == "" {
|
||||
@@ -1537,14 +1359,14 @@ func (c *cardConverter) convertPerson(prop cardObj, _ string) string {
|
||||
}
|
||||
if personName != "" {
|
||||
if c.mode == cardModeDetailed {
|
||||
return fmt.Sprintf("%s(open_id:%s)", personName, userID)
|
||||
return fmt.Sprintf("@%s(open_id:%s)", personName, userID)
|
||||
}
|
||||
return personName
|
||||
return "@" + personName
|
||||
}
|
||||
if c.mode == cardModeDetailed {
|
||||
return fmt.Sprintf("user(open_id:%s)", userID)
|
||||
return fmt.Sprintf("@user(open_id:%s)", userID)
|
||||
}
|
||||
return userID
|
||||
return "@" + userID
|
||||
}
|
||||
|
||||
// convertPersonV1 handles the v1 card schema person element.
|
||||
@@ -1560,14 +1382,14 @@ func (c *cardConverter) convertPersonV1(prop cardObj, _ string) string {
|
||||
personName := c.lookupPersonName(userID)
|
||||
if personName != "" {
|
||||
if c.mode == cardModeDetailed {
|
||||
return fmt.Sprintf("%s(open_id:%s)", personName, userID)
|
||||
return fmt.Sprintf("@%s(open_id:%s)", personName, userID)
|
||||
}
|
||||
return personName
|
||||
return "@" + personName
|
||||
}
|
||||
if c.mode == cardModeDetailed {
|
||||
return fmt.Sprintf("user(open_id:%s)", userID)
|
||||
return fmt.Sprintf("@user(open_id:%s)", userID)
|
||||
}
|
||||
return userID
|
||||
return "@" + userID
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertPersonList(prop cardObj) string {
|
||||
@@ -1582,21 +1404,10 @@ func (c *cardConverter) convertPersonList(prop cardObj) string {
|
||||
continue
|
||||
}
|
||||
personID, _ := pm["id"].(string)
|
||||
personName := c.lookupPersonName(personID)
|
||||
if personName != "" {
|
||||
if c.mode == cardModeDetailed {
|
||||
names = append(names, fmt.Sprintf("%s(open_id:%s)", personName, personID))
|
||||
} else {
|
||||
names = append(names, personName)
|
||||
}
|
||||
} else if personID != "" {
|
||||
if c.mode == cardModeDetailed {
|
||||
names = append(names, fmt.Sprintf("user(id:%s)", personID))
|
||||
} else {
|
||||
names = append(names, personID)
|
||||
}
|
||||
if c.mode == cardModeDetailed && personID != "" {
|
||||
names = append(names, fmt.Sprintf("@user(id:%s)", personID))
|
||||
} else {
|
||||
names = append(names, "user")
|
||||
names = append(names, "@user")
|
||||
}
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
@@ -1604,15 +1415,8 @@ func (c *cardConverter) convertPersonList(prop cardObj) string {
|
||||
|
||||
func (c *cardConverter) convertAvatar(prop cardObj, _ string) string {
|
||||
userID, _ := prop["userID"].(string)
|
||||
personName := c.lookupPersonName(userID)
|
||||
if personName != "" {
|
||||
if c.mode == cardModeDetailed {
|
||||
return fmt.Sprintf("👤 %s(open_id:%s)", personName, userID)
|
||||
}
|
||||
return "👤 " + personName
|
||||
}
|
||||
result := "👤"
|
||||
if userID != "" {
|
||||
if c.mode == cardModeDetailed && userID != "" {
|
||||
result += "(id:" + userID + ")"
|
||||
}
|
||||
return result
|
||||
@@ -1693,37 +1497,20 @@ func (c *cardConverter) lookupPersonName(userID string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// lookupOptionUserName resolves a user display name from the attachment's option_users map,
|
||||
// used for person-selector option labels.
|
||||
func (c *cardConverter) lookupOptionUserName(userID string) string {
|
||||
func (c *cardConverter) getImageToken(imageID string) string {
|
||||
if c.attachment == nil {
|
||||
return ""
|
||||
}
|
||||
if optUsers, ok := c.attachment["option_users"].(cardObj); ok {
|
||||
if userInfo, ok := optUsers[userID].(cardObj); ok {
|
||||
if content, ok := userInfo["content"].(string); ok {
|
||||
return content
|
||||
if images, ok := c.attachment["images"].(cardObj); ok {
|
||||
if imageInfo, ok := images[imageID].(cardObj); ok {
|
||||
if token, ok := imageInfo["token"].(string); ok {
|
||||
return token
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getImageKeyAndToken returns the origin_key and token for an image ID from the attachment map.
|
||||
// origin_key takes priority over token as the display-ready image reference.
|
||||
func (c *cardConverter) getImageKeyAndToken(imageID string) (originKey, token string) {
|
||||
if c.attachment == nil {
|
||||
return "", ""
|
||||
}
|
||||
if images, ok := c.attachment["images"].(cardObj); ok {
|
||||
if imageInfo, ok := images[imageID].(cardObj); ok {
|
||||
originKey, _ = imageInfo["origin_key"].(string)
|
||||
token, _ = imageInfo["token"].(string)
|
||||
}
|
||||
}
|
||||
return originKey, token
|
||||
}
|
||||
|
||||
type cardTextStyle struct {
|
||||
bold bool
|
||||
italic bool
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2620,45 +2620,3 @@ func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMessageIDs parses and validates the existing +messages comma-separated
|
||||
// flag format. Unlike splitByComma, it keeps empty entries so "id1,,id2" fails
|
||||
// locally. It intentionally does not enforce the server-side single-call limit:
|
||||
// fetchFullMessages chunks backend requests into batches of 20.
|
||||
func validateMessageIDs(raw string) ([]string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for i, part := range parts {
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, output.ErrValidation("--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
if part != id {
|
||||
return nil, output.ErrValidation("--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
|
||||
}
|
||||
if err := validateBatchGetMessageID(id, i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return nil, output.ErrValidation("--message-ids entry %d (%q): duplicate message ID is not allowed", i+1, id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func validateBatchGetMessageID(id string, index int) error {
|
||||
if strings.Trim(id, "0123456789") == "" {
|
||||
return output.ErrValidation("--message-ids entry %d (%q): numeric primary IDs are not supported by mail +messages; pass the Open API message_id from mail output", index+1, id)
|
||||
}
|
||||
decoded, err := base64.URLEncoding.DecodeString(id)
|
||||
if err != nil || len(decoded) == 0 {
|
||||
return output.ErrValidation("--message-ids entry %d (%q): expected a base64url Open API mail message_id from mail output", index+1, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package mail
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -18,8 +19,7 @@ type mailMessagesOutput struct {
|
||||
}
|
||||
|
||||
// MailMessages is the `+messages` shortcut: batch-fetch full content for
|
||||
// multiple message IDs, chunking backend calls into batches of 20 while
|
||||
// preserving request order.
|
||||
// up to 20 message IDs in a single call, preserving request order.
|
||||
var MailMessages = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+messages",
|
||||
@@ -35,15 +35,11 @@ var MailMessages = common.Shortcut{
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateBotMailboxNotMe(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := validateMessageIDs(runtime.Str("message-ids"))
|
||||
return err
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
messageIDs, _ := validateMessageIDs(runtime.Str("message-ids"))
|
||||
messageIDs := splitByComma(runtime.Str("message-ids"))
|
||||
body := map[string]interface{}{
|
||||
"format": messageGetFormat(runtime.Bool("html")),
|
||||
"message_ids": []string{"<message_id_1>", "<message_id_2>"},
|
||||
@@ -63,9 +59,9 @@ var MailMessages = common.Shortcut{
|
||||
}
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
hintIdentityFirst(runtime, mailboxID)
|
||||
messageIDs, err := validateMessageIDs(runtime.Str("message-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
messageIDs := splitByComma(runtime.Str("message-ids"))
|
||||
if len(messageIDs) == 0 {
|
||||
return output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
|
||||
}
|
||||
html := runtime.Bool("html")
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestMailMessagesExecuteChunksMoreThanTwentyIDs(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
ids := make([]string, 21)
|
||||
for i := range ids {
|
||||
ids[i] = base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("biz-%03d", i)))
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/batch_get",
|
||||
BodyFilter: requestMessageIDsEqual(ids[:20]),
|
||||
Body: batchGetMessagesResponse(ids[:20]),
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/batch_get",
|
||||
BodyFilter: requestMessageIDsEqual(ids[20:]),
|
||||
Body: batchGetMessagesResponse(ids[20:]),
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--message-ids", strings.Join(ids, ","),
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
out := decodeShortcutEnvelopeData(t, stdout)
|
||||
if got := int(out["total"].(float64)); got != len(ids) {
|
||||
t.Fatalf("total = %d, want %d; stdout=%s", got, len(ids), stdout.String())
|
||||
}
|
||||
messages, ok := out["messages"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("messages has unexpected type %T", out["messages"])
|
||||
}
|
||||
if len(messages) != len(ids) {
|
||||
t.Fatalf("messages length = %d, want %d", len(messages), len(ids))
|
||||
}
|
||||
for i, item := range messages {
|
||||
msg, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("messages[%d] has unexpected type %T", i, item)
|
||||
}
|
||||
if got := msg["message_id"]; got != ids[i] {
|
||||
t.Fatalf("messages[%d].message_id = %v, want %s", i, got, ids[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestMessageIDsEqual(want []string) func([]byte) bool {
|
||||
return func(body []byte) bool {
|
||||
var payload struct {
|
||||
MessageIDs []string `json:"message_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(payload.MessageIDs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func batchGetMessagesResponse(ids []string) map[string]interface{} {
|
||||
messages := make([]map[string]interface{}, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
messages = append(messages, map[string]interface{}{
|
||||
"message_id": id,
|
||||
"subject": id,
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"messages": messages,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -134,7 +133,7 @@ func TestMailMessageUserMailboxMePassesValidation(t *testing.T) {
|
||||
func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--as", "bot", "--message-ids", validMessageIDForTest("biz-x"),
|
||||
"+messages", "--as", "bot", "--message-ids", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
@@ -143,7 +142,7 @@ func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
|
||||
func TestMailMessagesBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", validMessageIDForTest("biz-x"),
|
||||
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
@@ -183,87 +182,3 @@ func TestMailTriageBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// --- message_ids validation tests (S2) ---
|
||||
|
||||
func validMessageIDForTest(s string) string {
|
||||
return base64.URLEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsAcceptsValidIDs(t *testing.T) {
|
||||
_, err := validateMessageIDs(validMessageIDForTest("biz-001") + "," + validMessageIDForTest("biz-002"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for valid IDs, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsEmpty(t *testing.T) {
|
||||
_, err := validateMessageIDs("")
|
||||
assertValidationError(t, err, "--message-ids is required")
|
||||
_, err = validateMessageIDs(" ")
|
||||
assertValidationError(t, err, "--message-ids is required")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsAcceptsMoreThanSingleBackendBatch(t *testing.T) {
|
||||
ids := make([]string, 21)
|
||||
for i := range ids {
|
||||
ids[i] = validMessageIDForTest(string(rune('a' + i)))
|
||||
}
|
||||
_, err := validateMessageIDs(strings.Join(ids, ","))
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for more than one backend batch, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsEmptyEntry(t *testing.T) {
|
||||
_, err := validateMessageIDs(validMessageIDForTest("biz-1") + ",," + validMessageIDForTest("biz-2"))
|
||||
assertValidationError(t, err, "entry 2 is empty")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsLeadingOrTrailingWhitespace(t *testing.T) {
|
||||
id1 := validMessageIDForTest("biz-1")
|
||||
id2 := validMessageIDForTest("biz-2")
|
||||
_, err := validateMessageIDs(id1 + ", " + id2)
|
||||
assertValidationError(t, err, "must not contain leading or trailing whitespace")
|
||||
_, err = validateMessageIDs(" " + id1 + "," + id2)
|
||||
assertValidationError(t, err, "must not contain leading or trailing whitespace")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsDuplicateIDs(t *testing.T) {
|
||||
id := validMessageIDForTest("biz-1")
|
||||
_, err := validateMessageIDs(id + "," + id)
|
||||
assertValidationError(t, err, "duplicate message ID is not allowed")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsJSONLikeInput(t *testing.T) {
|
||||
_, err := validateMessageIDs(`["id1","id2"]`)
|
||||
assertValidationError(t, err, "expected a base64url")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsColonJoinedInput(t *testing.T) {
|
||||
_, err := validateMessageIDs("id1:id2")
|
||||
assertValidationError(t, err, "expected a base64url")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsNumericPrimaryID(t *testing.T) {
|
||||
_, err := validateMessageIDs("123456789")
|
||||
assertValidationError(t, err, "numeric primary IDs are not supported")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsAcceptsExactlyTwenty(t *testing.T) {
|
||||
ids := make([]string, 20)
|
||||
for i := range ids {
|
||||
ids[i] = validMessageIDForTest(string(rune('A' + i)))
|
||||
}
|
||||
_, err := validateMessageIDs(strings.Join(ids, ","))
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for exactly 20 IDs, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessageIDRejectsInvalidBase64(t *testing.T) {
|
||||
_, err := validateMessageIDs("msg 1")
|
||||
assertValidationError(t, err, "expected a base64url")
|
||||
_, err = validateMessageIDs("not-base64!")
|
||||
assertValidationError(t, err, "expected a base64url")
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ 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"
|
||||
@@ -30,7 +29,6 @@ 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"
|
||||
@@ -55,40 +53,55 @@ func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool {
|
||||
return slices.Contains(allowed, brand)
|
||||
}
|
||||
|
||||
// allShortcuts aggregates shortcuts from all domain packages.
|
||||
var allShortcuts []common.Shortcut
|
||||
// allShortcuts aggregates shortcuts from all domain packages. Each entry is a
|
||||
// Mountable: both legacy common.Shortcut (via *Shortcut after the descriptor
|
||||
// accessor methods landed) and the new generic common.TypedShortcut[T] satisfy
|
||||
// it. The slice element type is ShortcutDescriptor so read-only consumers
|
||||
// (auth login, scope hint, shortcuts.json generator) can read metadata without
|
||||
// caring about which concrete implementation backs each entry.
|
||||
//
|
||||
// Only legacy shortcuts are registered today; the typed track stays wired
|
||||
// (ShortcutDescriptor / Mountable dispatch + buildTypedHelp) but currently has
|
||||
// no domain caller.
|
||||
var allShortcuts []common.ShortcutDescriptor
|
||||
|
||||
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()...)
|
||||
// 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().
|
||||
allShortcuts = append(allShortcuts, wrapSheetsBackwardDeprecation(sheetsbackward.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()...)
|
||||
// addLegacy boxes a legacy []common.Shortcut into the descriptor slice. We use
|
||||
// pointer-valued elements because the pointer-receiver scope methods
|
||||
// (ScopesForIdentity / ConditionalScopesForIdentity / DeclaredScopesForIdentity)
|
||||
// are required by ShortcutDescriptor — value receivers don't satisfy them.
|
||||
func addLegacy(list []common.Shortcut) {
|
||||
for i := range list {
|
||||
allShortcuts = append(allShortcuts, &list[i])
|
||||
}
|
||||
}
|
||||
|
||||
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
|
||||
func init() {
|
||||
addLegacy(apps.Shortcuts())
|
||||
addLegacy(calendar.Shortcuts())
|
||||
addLegacy(doc.Shortcuts())
|
||||
addLegacy(drive.Shortcuts())
|
||||
addLegacy(im.Shortcuts())
|
||||
addLegacy(contact_shortcuts.Shortcuts())
|
||||
addLegacy(sheets.Shortcuts())
|
||||
addLegacy(base.Shortcuts())
|
||||
addLegacy(event.Shortcuts())
|
||||
addLegacy(mail.Shortcuts())
|
||||
addLegacy(markdown.Shortcuts())
|
||||
addLegacy(slides.Shortcuts())
|
||||
addLegacy(minutes.Shortcuts())
|
||||
addLegacy(task.Shortcuts())
|
||||
addLegacy(vc.Shortcuts())
|
||||
addLegacy(whiteboard.Shortcuts())
|
||||
addLegacy(wiki.Shortcuts())
|
||||
addLegacy(okr.Shortcuts())
|
||||
}
|
||||
|
||||
// AllShortcuts returns a copy of all registered shortcut descriptors (for
|
||||
// dump-shortcuts and auth/scope-hint consumers).
|
||||
//
|
||||
//go:noinline
|
||||
func AllShortcuts() []common.Shortcut {
|
||||
return append([]common.Shortcut(nil), allShortcuts...)
|
||||
func AllShortcuts() []common.ShortcutDescriptor {
|
||||
return append([]common.ShortcutDescriptor(nil), allShortcuts...)
|
||||
}
|
||||
|
||||
// RegisterShortcuts registers all +shortcut commands on the program.
|
||||
@@ -105,10 +118,15 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
}
|
||||
}
|
||||
|
||||
// Group by service
|
||||
byService := make(map[string][]common.Shortcut)
|
||||
for _, s := range allShortcuts {
|
||||
byService[s.Service] = append(byService[s.Service], s)
|
||||
// Group by service. Each entry is a Mountable so MountWithContext works
|
||||
// uniformly across legacy *Shortcut and TypedShortcut[T].
|
||||
byService := make(map[string][]common.Mountable)
|
||||
for _, d := range allShortcuts {
|
||||
m, ok := d.(common.Mountable)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("shortcut %s/%s missing Mountable", d.GetService(), d.GetCommand()))
|
||||
}
|
||||
byService[d.GetService()] = append(byService[d.GetService()], m)
|
||||
}
|
||||
|
||||
for service, shortcuts := range byService {
|
||||
@@ -147,15 +165,12 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
doc.ConfigureServiceHelp(svc)
|
||||
}
|
||||
|
||||
for _, shortcut := range shortcuts {
|
||||
shortcut.MountWithContext(ctx, svc, f)
|
||||
for _, m := range shortcuts {
|
||||
m.MountWithContext(ctx, svc, f)
|
||||
}
|
||||
if service == "mail" {
|
||||
mail.InstallOnMail(svc)
|
||||
}
|
||||
if service == "sheets" {
|
||||
applySheetsCompatGroups(svc)
|
||||
}
|
||||
|
||||
if !IsShortcutServiceAvailable(service, brand) {
|
||||
installBrandRestrictionGuard(svc, service, brand)
|
||||
@@ -199,153 +214,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package shortcuts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -17,7 +16,6 @@ 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"
|
||||
@@ -50,10 +48,16 @@ func newRegisterTestProgramWithTipsHelp() *cobra.Command {
|
||||
}
|
||||
|
||||
func TestAllShortcutsScopesNotNil(t *testing.T) {
|
||||
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)
|
||||
for _, d := range allShortcuts {
|
||||
legacy, ok := d.(*common.Shortcut)
|
||||
if !ok {
|
||||
// Typed shortcuts enforce scope-presence at their own test layer
|
||||
// (TypedShortcut[T] integration tests); the descriptor interface
|
||||
// doesn't expose the raw fields needed for the nil-vs-empty check.
|
||||
continue
|
||||
}
|
||||
if legacy.Scopes == nil && legacy.UserScopes == nil && legacy.BotScopes == nil {
|
||||
t.Errorf("shortcut %s/%s: Scopes is nil (must be explicitly set, use []string{} if no scopes needed)", legacy.Service, legacy.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,8 +69,8 @@ func TestAllShortcutsReturnsCopyAndIncludesBase(t *testing.T) {
|
||||
}
|
||||
|
||||
hasBaseGet := false
|
||||
for _, shortcut := range shortcuts {
|
||||
if shortcut.Service == "base" && shortcut.Command == "+base-get" {
|
||||
for _, d := range shortcuts {
|
||||
if d.GetService() == "base" && d.GetCommand() == "+base-get" {
|
||||
hasBaseGet = true
|
||||
break
|
||||
}
|
||||
@@ -75,9 +79,29 @@ func TestAllShortcutsReturnsCopyAndIncludesBase(t *testing.T) {
|
||||
t.Fatal("AllShortcuts does not include base/+base-get")
|
||||
}
|
||||
|
||||
shortcuts[0].Service = "mutated"
|
||||
if AllShortcuts()[0].Service == "mutated" {
|
||||
t.Fatal("AllShortcuts should return a copy")
|
||||
// Returned slice is a defensive copy of the descriptor references;
|
||||
// appending to it must not affect the canonical registry. (Element
|
||||
// identity is shared by design — mutation through pointers is the
|
||||
// caller's responsibility to avoid, same as any interface slice.)
|
||||
got := AllShortcuts()
|
||||
got = append(got, &common.Shortcut{Service: "synthetic"})
|
||||
if len(AllShortcuts()) >= len(got) {
|
||||
t.Fatal("AllShortcuts should return a copy that callers can append to without affecting the registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcuts_ReturnsShortcutDescriptors(t *testing.T) {
|
||||
list := AllShortcuts()
|
||||
if len(list) == 0 {
|
||||
t.Fatal("AllShortcuts returned empty slice")
|
||||
}
|
||||
for _, d := range list {
|
||||
if d.GetService() == "" {
|
||||
t.Errorf("shortcut missing service: %T", d)
|
||||
}
|
||||
if d.GetCommand() == "" {
|
||||
t.Errorf("shortcut %q missing command", d.GetService())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,10 +478,10 @@ func TestGenerateShortcutsJSON(t *testing.T) {
|
||||
}
|
||||
grouped := make(map[string][]entry)
|
||||
for _, s := range shortcuts {
|
||||
verb := strings.TrimPrefix(s.Command, "+")
|
||||
grouped[s.Service] = append(grouped[s.Service], entry{
|
||||
verb := strings.TrimPrefix(s.GetCommand(), "+")
|
||||
grouped[s.GetService()] = append(grouped[s.GetService()], entry{
|
||||
Verb: verb,
|
||||
Description: s.Description,
|
||||
Description: s.GetDescription(),
|
||||
Scopes: s.DeclaredScopesForIdentity("user"),
|
||||
})
|
||||
}
|
||||
@@ -474,152 +498,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -1,907 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestBatchOp_BodyMatchesStandalone is the core contract: for every batchable
|
||||
// shortcut, the MCP body produced inside +batch-update must be byte-for-byte
|
||||
// identical to the body the same shortcut produces when invoked standalone
|
||||
// (both observed via --dry-run, comparing tool_name + decoded input). This is
|
||||
// what guarantees "a sub-op behaves exactly like the standalone command", and
|
||||
// it is the regression guard for the whole flag→body translator reuse.
|
||||
//
|
||||
// Each case provides the standalone CLI args and the equivalent sub-op input
|
||||
// object (same CLI flag names, minus the spreadsheet locator which the batch
|
||||
// supplies at the top level).
|
||||
func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
shortcut string
|
||||
sc common.Shortcut
|
||||
// standalone args (excluding --url, which every case shares)
|
||||
args []string
|
||||
// sub-op input object as JSON (CLI flag names; no excel_id/url)
|
||||
subInput string
|
||||
}{
|
||||
{
|
||||
shortcut: "+cells-set",
|
||||
sc: CellsSet,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:B1", "--cells", `[[{"value":"x"},{"value":"y"}]]`},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:B1","cells":[[{"value":"x"},{"value":"y"}]]}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cells-clear",
|
||||
sc: CellsClear,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:C3", "--scope", "formats"},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:C3","scope":"formats"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cells-replace",
|
||||
sc: CellsReplace,
|
||||
args: []string{"--sheet-id", "sh1", "--find", "foo", "--replacement", "bar", "--match-case"},
|
||||
subInput: `{"sheet-id":"sh1","find":"foo","replacement":"bar","match-case":true}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+csv-put",
|
||||
sc: CsvPut,
|
||||
args: []string{"--sheet-id", "sh1", "--csv", "a,b\n1,2", "--start-cell", "B2"},
|
||||
subInput: `{"sheet-id":"sh1","csv":"a,b\n1,2","start-cell":"B2"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cells-merge",
|
||||
sc: CellsMerge,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:C1", "--merge-type", "rows"},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:C1","merge-type":"rows"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cells-unmerge",
|
||||
sc: CellsUnmerge,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:C1"},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:C1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dim-insert",
|
||||
sc: DimInsert,
|
||||
args: []string{"--sheet-id", "sh1", "--position", "11", "--count", "2", "--inherit-style", "before"},
|
||||
subInput: `{"sheet-id":"sh1","position":"11","count":2,"inherit-style":"before"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dim-delete",
|
||||
sc: DimDelete,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "C:D"},
|
||||
subInput: `{"sheet-id":"sh1","range":"C:D"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dim-hide",
|
||||
sc: DimHide,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "2:3"},
|
||||
subInput: `{"sheet-id":"sh1","range":"2:3"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dim-freeze",
|
||||
sc: DimFreeze,
|
||||
args: []string{"--sheet-id", "sh1", "--dimension", "row", "--count", "2"},
|
||||
subInput: `{"sheet-id":"sh1","dimension":"row","count":2}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dim-group",
|
||||
sc: DimGroup,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "2:5", "--group-state", "fold"},
|
||||
subInput: `{"sheet-id":"sh1","range":"2:5","group-state":"fold"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+rows-resize",
|
||||
sc: RowsResize,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1", "--type", "pixel", "--size", "30"},
|
||||
subInput: `{"sheet-id":"sh1","range":"1","type":"pixel","size":30}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cols-resize",
|
||||
sc: ColsResize,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "B:D", "--type", "standard"},
|
||||
subInput: `{"sheet-id":"sh1","range":"B:D","type":"standard"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+range-move",
|
||||
sc: RangeMove,
|
||||
args: []string{"--sheet-id", "sh1", "--source-range", "A1:C5", "--target-range", "D1"},
|
||||
subInput: `{"sheet-id":"sh1","source-range":"A1:C5","target-range":"D1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+range-copy",
|
||||
sc: RangeCopy,
|
||||
args: []string{"--sheet-id", "sh1", "--source-range", "A1:B2", "--target-range", "A10", "--paste-type", "values"},
|
||||
subInput: `{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"values"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+range-fill",
|
||||
sc: RangeFill,
|
||||
args: []string{"--sheet-id", "sh1", "--source-range", "A1:A2", "--target-range", "A1:A10", "--series-type", "linear"},
|
||||
subInput: `{"sheet-id":"sh1","source-range":"A1:A2","target-range":"A1:A10","series-type":"linear"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+range-sort",
|
||||
sc: RangeSort,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:D10", "--sort-keys", `[{"column":"B","ascending":true}]`, "--has-header"},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:D10","sort-keys":[{"column":"B","ascending":true}],"has-header":true}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-create",
|
||||
sc: SheetCreate,
|
||||
args: []string{"--title", "New", "--index", "2"},
|
||||
subInput: `{"title":"New","index":2}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-delete",
|
||||
sc: SheetDelete,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-rename",
|
||||
sc: SheetRename,
|
||||
args: []string{"--sheet-id", "sh1", "--title", "Renamed"},
|
||||
subInput: `{"sheet-id":"sh1","title":"Renamed"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-copy",
|
||||
sc: SheetCopy,
|
||||
args: []string{"--sheet-id", "sh1", "--title", "Copy"},
|
||||
subInput: `{"sheet-id":"sh1","title":"Copy"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-hide",
|
||||
sc: SheetHide,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-unhide",
|
||||
sc: SheetUnhide,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-set-tab-color",
|
||||
sc: SheetSetTabColor,
|
||||
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
|
||||
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dropdown-set",
|
||||
sc: DropdownSet,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--multiple"},
|
||||
subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"multiple":true}`,
|
||||
},
|
||||
{
|
||||
// --highlight=false explicitly opts out of the server's new
|
||||
// enable_highlight=true default. Covers the tri-state Changed()
|
||||
// branch in buildDropdownValidation: standalone reads the cobra
|
||||
// "Changed" bit; sub-op reads the key's presence in the map.
|
||||
shortcut: "+dropdown-set",
|
||||
sc: DropdownSet,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--highlight=false"},
|
||||
subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"highlight":false}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+chart-create",
|
||||
sc: ChartCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
|
||||
subInput: `{"sheet-id":"sh1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+chart-update",
|
||||
sc: ChartUpdate,
|
||||
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
|
||||
subInput: `{"sheet-id":"sh1","chart-id":"c1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+chart-delete",
|
||||
sc: ChartDelete,
|
||||
args: []string{"--sheet-id", "sh1", "--chart-id", "c1"},
|
||||
subInput: `{"sheet-id":"sh1","chart-id":"c1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+pivot-create",
|
||||
sc: PivotCreate,
|
||||
// +pivot-create renamed --sheet-id / --sheet-name → --target-sheet-id /
|
||||
// --target-sheet-name to flag the placement-sheet semantics (the data
|
||||
// source is in --source). Both standalone args and the +batch-update
|
||||
// sub-op input must use the new names.
|
||||
args: []string{"--target-sheet-id", "sh1", "--properties", `{"rows":[]}`, "--source", "Sheet1!A1:D100"},
|
||||
subInput: `{"target-sheet-id":"sh1","properties":{"rows":[]},"source":"Sheet1!A1:D100"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cond-format-create",
|
||||
sc: CondFormatCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--properties", `{"style":{}}`, "--rule-type", "duplicateValues", "--ranges", `["A1:A100"]`},
|
||||
subInput: `{"sheet-id":"sh1","properties":{"style":{}},"rule-type":"duplicateValues","ranges":["A1:A100"]}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+filter-create",
|
||||
sc: FilterCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+filter-update",
|
||||
sc: FilterUpdate,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+filter-delete",
|
||||
sc: FilterDelete,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+filter-view-create",
|
||||
sc: FilterViewCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:Z100", "--view-name", "v1", "--properties", `{"rules":[]}`},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:Z100","view-name":"v1","properties":{"rules":[]}}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sparkline-create",
|
||||
sc: SparklineCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--properties", `{"type":"line","data_range":"A2:F2","target_range":"G2"}`},
|
||||
subInput: `{"sheet-id":"sh1","properties":{"type":"line","data_range":"A2:F2","target_range":"G2"}}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sparkline-delete",
|
||||
sc: SparklineDelete,
|
||||
args: []string{"--sheet-id", "sh1", "--group-id", "g1"},
|
||||
subInput: `{"sheet-id":"sh1","group-id":"g1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+float-image-create",
|
||||
sc: FloatImageCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--image-name", "logo.png", "--image-token", "tok", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"},
|
||||
subInput: `{"sheet-id":"sh1","image-name":"logo.png","image-token":"tok","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+float-image-delete",
|
||||
sc: FloatImageDelete,
|
||||
args: []string{"--sheet-id", "sh1", "--float-image-id", "fi1"},
|
||||
subInput: `{"sheet-id":"sh1","float-image-id":"fi1"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.shortcut, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mapping, ok := batchOpDispatch[tc.shortcut]
|
||||
if !ok {
|
||||
t.Fatalf("%s not in batchOpDispatch", tc.shortcut)
|
||||
}
|
||||
|
||||
// Standalone body via the shortcut's own dry-run.
|
||||
standaloneBody := decodeToolInput(t, parseDryRunBody(t, tc.sc, append([]string{"--url", testURL}, tc.args...)), mapping.mcpToolName)
|
||||
|
||||
// Batch body via the +batch-update translator.
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
||||
t.Fatalf("bad subInput JSON: %v", err)
|
||||
}
|
||||
fv := newMapFlagViewForCommand(tc.shortcut, subInput)
|
||||
// Match what translateBatchOp does — read the sheet selector
|
||||
// via the shortcut-specific flag names so +pivot-create
|
||||
// (target-sheet-id / target-sheet-name) and the rest
|
||||
// (sheet-id / sheet-name) both resolve correctly.
|
||||
sidFlag, snameFlag := sheetSelectorFlagsForSubOp(tc.shortcut)
|
||||
sidStr, _ := subInput[sidFlag].(string)
|
||||
snameStr, _ := subInput[snameFlag].(string)
|
||||
batchBody, err := mapping.translate(fv, testToken, sidStr, snameStr)
|
||||
if err != nil {
|
||||
t.Fatalf("batch translate failed: %v", err)
|
||||
}
|
||||
|
||||
// Round-trip the batch body through JSON so number types match the
|
||||
// standalone path (which is decoded from a JSON string).
|
||||
batchBody = jsonRoundTrip(t, batchBody)
|
||||
|
||||
if !reflect.DeepEqual(standaloneBody, batchBody) {
|
||||
t.Errorf("%s: batch body != standalone body\n standalone=%#v\n batch =%#v", tc.shortcut, standaloneBody, batchBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func jsonRoundTrip(t *testing.T, m map[string]interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestBatchOp_ErrorEquivalence is the second half of the contract: for the
|
||||
// same bad input, the standalone shortcut Validate and the +batch-update
|
||||
// sub-op translator must emit the same friendly CLI error. Previously a
|
||||
// sub-op that omitted --sheet-id (or another required flag) slipped through
|
||||
// to the server and surfaced as "sheet undefined not found"; with the
|
||||
// validation pushed down into the xxxInput builders both paths now stop the
|
||||
// request before the API call.
|
||||
//
|
||||
// Scope: this test covers checks that cobra cannot enforce — XOR pairs
|
||||
// (sheet selector, image token/uri), range relationships, enum-bound rules,
|
||||
// pixel/size cross-flag coupling. cobra's own MarkFlagRequired catches the
|
||||
// single-required cases on the standalone path with its own
|
||||
// "required flag(s) \"X\" not set" wording; the batch path now catches the
|
||||
// same situations with our friendlier "--X is required" wording — those are
|
||||
// asserted by TestBatchOp_RejectsBadSubOpInput below.
|
||||
func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
// shortcut & standalone args. --url is supplied by the runner. Args
|
||||
// satisfy every cobra-required flag so cobra doesn't short-circuit
|
||||
// before our shared validator runs.
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
// matching sub-op input; reach the same failing check.
|
||||
subShortcut string
|
||||
subInput string
|
||||
// substring expected in both errors. We assert *contains* rather than
|
||||
// equality because the batch path wraps the inner error with
|
||||
// "operations[i] (<name>): " context — the inner message must match.
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "+cells-set missing sheet selector",
|
||||
shortcut: CellsSet,
|
||||
args: []string{"--range", "A1", "--cells", `[[{"value":"x"}]]`},
|
||||
subShortcut: "+cells-set",
|
||||
subInput: `{"range":"A1","cells":[[{"value":"x"}]]}`,
|
||||
wantContains: "specify at least one of --sheet-id or --sheet-name",
|
||||
},
|
||||
{
|
||||
name: "+cells-set both sheet-id and sheet-name",
|
||||
shortcut: CellsSet,
|
||||
args: []string{"--sheet-id", "sh1", "--sheet-name", "Sheet1", "--range", "A1", "--cells", `[[{"value":"x"}]]`},
|
||||
subShortcut: "+cells-set",
|
||||
subInput: `{"sheet-id":"sh1","sheet-name":"Sheet1","range":"A1","cells":[[{"value":"x"}]]}`,
|
||||
wantContains: "mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "+dim-insert missing sheet selector",
|
||||
shortcut: DimInsert,
|
||||
args: []string{"--position", "1", "--count", "1"},
|
||||
subShortcut: "+dim-insert",
|
||||
subInput: `{"position":"1","count":1}`,
|
||||
wantContains: "specify at least one of --sheet-id or --sheet-name",
|
||||
},
|
||||
{
|
||||
name: "+dim-insert count <= 0",
|
||||
shortcut: DimInsert,
|
||||
args: []string{"--sheet-id", "sh1", "--position", "5", "--count", "0"},
|
||||
subShortcut: "+dim-insert",
|
||||
subInput: `{"sheet-id":"sh1","position":"5","count":0}`,
|
||||
wantContains: "--count must be > 0",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --type pixel without --size",
|
||||
shortcut: RowsResize,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"},
|
||||
subShortcut: "+rows-resize",
|
||||
subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`,
|
||||
wantContains: "--type pixel requires --size",
|
||||
},
|
||||
{
|
||||
name: "+sheet-delete missing sheet selector",
|
||||
shortcut: SheetDelete,
|
||||
args: []string{},
|
||||
subShortcut: "+sheet-delete",
|
||||
subInput: `{}`,
|
||||
wantContains: "specify at least one of --sheet-id or --sheet-name",
|
||||
},
|
||||
{
|
||||
name: "+float-image-create both image-token and image-uri",
|
||||
shortcut: FloatImageCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--image-name", "x.png", "--image-token", "t", "--image-uri", "u", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"},
|
||||
subShortcut: "+float-image-create",
|
||||
subInput: `{"sheet-id":"sh1","image-name":"x.png","image-token":"t","image-uri":"u","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
||||
wantContains: "mutually exclusive",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Standalone path: run the shortcut with --dry-run + bad args.
|
||||
// Validate runs before DryRun, so we expect it to fail there.
|
||||
_, _, standaloneErr := runShortcutCapturingErr(
|
||||
t, tc.shortcut,
|
||||
append([]string{"--url", testURL, "--dry-run"}, tc.args...),
|
||||
)
|
||||
if standaloneErr == nil {
|
||||
t.Fatalf("standalone Validate accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(standaloneErr.Error(), tc.wantContains) {
|
||||
t.Errorf("standalone error = %q, want substring %q", standaloneErr.Error(), tc.wantContains)
|
||||
}
|
||||
|
||||
// Batch path: translate the matching sub-op. The translator wraps
|
||||
// the inner error with "operations[i] (<shortcut>): " — assert the
|
||||
// inner message survives the wrap.
|
||||
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,
|
||||
}
|
||||
_, batchErr := translateBatchOp(rawOp, testToken, 0)
|
||||
if batchErr == nil {
|
||||
t.Fatalf("batch translator accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(batchErr.Error(), tc.wantContains) {
|
||||
t.Errorf("batch error = %q, want substring %q (operations[i] prefix is fine)", batchErr.Error(), tc.wantContains)
|
||||
}
|
||||
// And the wrap context must include the sub-op index + shortcut
|
||||
// name so error reports stay actionable in multi-op batches.
|
||||
wrapHint := "operations[0] (" + tc.subShortcut + "):"
|
||||
if !strings.Contains(batchErr.Error(), wrapHint) {
|
||||
t.Errorf("batch error %q missing context prefix %q", batchErr.Error(), wrapHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
},
|
||||
{
|
||||
// Standalone cobra rejects 1.5 for an int flag at parse time;
|
||||
// mapFlagView.Int would silently truncate it to 1, so the batch
|
||||
// path must reject it too instead of executing on a floored index.
|
||||
name: "int flag given a non-integer number",
|
||||
subShortcut: "+sheet-move",
|
||||
subInput: `{"sheet-id":"sh1","source-index":2,"index":1.5}`,
|
||||
wantContains: "--index must be an integer",
|
||||
},
|
||||
{
|
||||
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
|
||||
// CLI-side with its own friendly error before issuing any API call. This
|
||||
// closes the original bug — a sub-op missing --sheet-id used to slip
|
||||
// through and surface as "sheet undefined not found" only after a
|
||||
// network round-trip.
|
||||
func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
subShortcut string
|
||||
subInput string
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
"+cells-set missing --range",
|
||||
"+cells-set",
|
||||
`{"sheet-id":"sh1","cells":[[{"value":"x"}]]}`,
|
||||
"--range is required",
|
||||
},
|
||||
{
|
||||
"+dim-insert missing --position",
|
||||
"+dim-insert",
|
||||
`{"sheet-id":"sh1","count":1}`,
|
||||
"--position is required",
|
||||
},
|
||||
{
|
||||
"+rows-resize missing --type",
|
||||
"+rows-resize",
|
||||
`{"sheet-id":"sh1","range":"1:1"}`,
|
||||
"--type is required",
|
||||
},
|
||||
{
|
||||
"+range-copy missing --target-range",
|
||||
"+range-copy",
|
||||
`{"sheet-id":"sh1","source-range":"A1:B2"}`,
|
||||
"--target-range is required",
|
||||
},
|
||||
{
|
||||
"+sheet-rename missing --title",
|
||||
"+sheet-rename",
|
||||
`{"sheet-id":"sh1"}`,
|
||||
"--title is required",
|
||||
},
|
||||
{
|
||||
"+chart-update missing --chart-id",
|
||||
"+chart-update",
|
||||
`{"sheet-id":"sh1","properties":{"title":"T"}}`,
|
||||
"--chart-id is required",
|
||||
},
|
||||
{
|
||||
"+filter-create missing --range",
|
||||
"+filter-create",
|
||||
`{"sheet-id":"sh1"}`,
|
||||
"--range is required",
|
||||
},
|
||||
{
|
||||
"+float-image-update missing --float-image-id",
|
||||
"+float-image-update",
|
||||
`{"sheet-id":"sh1","image-name":"x.png","image-token":"t","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
||||
"--float-image-id is required",
|
||||
},
|
||||
// +float-image-update's core (image_name / position / size) is mandatory
|
||||
// on update too — the tool rejects without them and +float-image-list
|
||||
// can't backfill image_name. cobra gates these on the standalone path;
|
||||
// the batch sub-op must reject them here. The image source stays optional
|
||||
// (omitting it keeps the current image), so these inputs omit it.
|
||||
{
|
||||
"+float-image-update missing --image-name",
|
||||
"+float-image-update",
|
||||
`{"sheet-id":"sh1","float-image-id":"fi1","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
||||
"--image-name is required",
|
||||
},
|
||||
{
|
||||
"+float-image-update missing position",
|
||||
"+float-image-update",
|
||||
`{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","size-width":100,"size-height":50}`,
|
||||
"--position-row and --position-col are required",
|
||||
},
|
||||
{
|
||||
"+float-image-update missing size",
|
||||
"+float-image-update",
|
||||
`{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","position-row":0,"position-col":"A"}`,
|
||||
"--size-width and --size-height are required",
|
||||
},
|
||||
// +filter-{update,delete} need sheet-id (not sheet-name) because
|
||||
// server contract: filter_id === sheet_id, and we can't resolve
|
||||
// sheet-name → sheet-id mid-batch.
|
||||
{
|
||||
"+filter-update with --sheet-name only (filter_id must equal sheet_id)",
|
||||
"+filter-update",
|
||||
`{"sheet-name":"Sheet1","range":"A1:F1000","properties":{"rules":[]}}`,
|
||||
"+filter-update requires --sheet-id",
|
||||
},
|
||||
{
|
||||
"+filter-delete with --sheet-name only (filter_id must equal sheet_id)",
|
||||
"+filter-delete",
|
||||
`{"sheet-name":"Sheet1"}`,
|
||||
"+filter-delete requires --sheet-id",
|
||||
},
|
||||
// +sparkline-update requires sparkline_id on every
|
||||
// properties.sparklines[i] (server contract). CLI surfaces this
|
||||
// with a pointer to +sparkline-list so the agent doesn't have to
|
||||
// guess the id from an opaque server-side rejection.
|
||||
{
|
||||
"+sparkline-update item missing sparkline_id",
|
||||
"+sparkline-update",
|
||||
`{"sheet-id":"sh1","group-id":"g1","properties":{"sparklines":[{"position":{"row":0,"col":"A"}}]}}`,
|
||||
"missing sparkline_id",
|
||||
},
|
||||
}
|
||||
|
||||
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("translator accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_SchemaValidatesSubOps confirms the schema-driven
|
||||
// validator fires on +batch-update sub-operations the same way it
|
||||
// fires on standalone shortcuts. mapFlagView.Command() returns the
|
||||
// sub-op's shortcut name, so validateInputAgainstSchema (called at
|
||||
// each input builder's tail) routes through the same (command, flag)
|
||||
// lookup pipeline a standalone invocation would. This regression
|
||||
// pins that wiring — without it, agents could slip past CLI-side
|
||||
// schema checks by wrapping a bad input in +batch-update.
|
||||
func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
subShortcut string
|
||||
subInput string
|
||||
wantContains string
|
||||
}{
|
||||
// +pivot-create properties.values items enforce summarize_by
|
||||
// enum — schema rejects an out-of-enum value as a sub-op too.
|
||||
{
|
||||
"+pivot-create summarize_by out of enum",
|
||||
"+pivot-create",
|
||||
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
||||
"summarize_by",
|
||||
},
|
||||
// +chart-create properties.position.row has minimum:0 — P0
|
||||
// addition; validator must catch -1 even in the batch path.
|
||||
{
|
||||
"+chart-create position.row below minimum",
|
||||
"+chart-create",
|
||||
`{"sheet-id":"sh1","properties":{"position":{"row":-1,"col":"A"},"size":{"width":400,"height":300}}}`,
|
||||
"below minimum",
|
||||
},
|
||||
// +cells-set --cells is a 2D array of objects per the
|
||||
// upstream-fixed schema; sub-op passing an object must be
|
||||
// rejected at the schema layer (not "expected JSON array").
|
||||
{
|
||||
"+cells-set cells wrong shape",
|
||||
"+cells-set",
|
||||
`{"sheet-id":"sh1","range":"A1","cells":{"foo":"bar"}}`,
|
||||
`expected type "array"`,
|
||||
},
|
||||
}
|
||||
|
||||
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("translator accepted schema-violating sub-op — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_DispatchCoversReportedBugs is a focused guard for the two
|
||||
// originally reported failures: +range-copy and +rows-resize sub-ops must
|
||||
// translate to the correct MCP body (not a near-passthrough that drops
|
||||
// required fields).
|
||||
func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// +range-copy → transform_range with range / destination_range (not the
|
||||
// raw source_range / target_range that used to leak through).
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+range-copy","input":{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"all"}}]`,
|
||||
"--yes",
|
||||
})
|
||||
ops := decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
|
||||
copyIn := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if copyIn["range"] != "A1:B2" || copyIn["destination_range"] != "A10" {
|
||||
t.Errorf("+range-copy sub-op body wrong: %#v", copyIn)
|
||||
}
|
||||
if copyIn["operation"] != "copy" {
|
||||
t.Errorf("+range-copy operation = %v, want copy", copyIn["operation"])
|
||||
}
|
||||
|
||||
// +rows-resize → resize_range with range + resize_height. The CLI's single
|
||||
// "23" input must be expanded to "23:23" because resize_range rejects
|
||||
// bare single-element ranges.
|
||||
body = parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","type":"pixel","size":40}}]`,
|
||||
"--yes",
|
||||
})
|
||||
ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
|
||||
resizeIn := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if resizeIn["range"] != "23:23" {
|
||||
t.Errorf("+rows-resize single-row range = %v, want 23:23", resizeIn["range"])
|
||||
}
|
||||
rh, _ := resizeIn["resize_height"].(map[string]interface{})
|
||||
if rh == nil || rh["type"] != "pixel" {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── +batch-update sub-op dispatch ─────────────────────────────────────
|
||||
//
|
||||
// 用户传给 +batch-update --operations 的形态是 CLI 视角的 {shortcut, input}:
|
||||
//
|
||||
// [{"shortcut": "+range-copy", "input": {"sheet_id":"...","source-range":"A1:B2","target-range":"A10"}}, ...]
|
||||
//
|
||||
// input 里用的是该 shortcut 的 **CLI flag 名**(与 standalone 调用一致;连字符 /
|
||||
// 下划线两种写法都接受)。底层 MCP batch_update tool 要的是
|
||||
// {tool_name, input(MCP body)} —— body 的字段名往往与 CLI flag 名不同
|
||||
// (如 +range-copy 的 source-range/target-range 要翻成 range/destination_range)。
|
||||
//
|
||||
// 关键:每个子操作复用 **standalone shortcut 同一套 flag→body translator**
|
||||
// (那些 *Input 构建函数,现在统一接收 flagView 接口)。这样 batch 子操作
|
||||
// 产出的 MCP body 与该 shortcut 单独调用产出的 body 完全一致(由
|
||||
// batch-vs-standalone 契约测试保证)。dispatch 表只列**可纳入 atomic batch
|
||||
// 的 write shortcut**——读操作、fan-out wrapper(+batch-update 自身、
|
||||
// +cells-batch-set-style、+cells-batch-clear、+dropdown-{update,delete})一律不放进表里,
|
||||
// 用户传到 +batch-update 里会被 translator 拒绝。
|
||||
|
||||
// batchTranslateFn turns a sub-op's CLI-shape input (via flagView) into the MCP
|
||||
// tool body for the underlying batch_update sub-tool. token is the
|
||||
// +batch-update top-level spreadsheet token; sheetID/sheetName are the resolved
|
||||
// sheet selector for this sub-op. The returned body already carries excel_id
|
||||
// and (where the tool needs one) the operation discriminator — exactly as the
|
||||
// standalone shortcut would emit.
|
||||
type batchTranslateFn func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error)
|
||||
|
||||
type batchOpMapping struct {
|
||||
// mcpToolName 是底层 MCP batch_update 接受的 tool_name。
|
||||
mcpToolName string
|
||||
// translate 复用 standalone 的 *Input 构建逻辑,产出 MCP body。
|
||||
translate batchTranslateFn
|
||||
}
|
||||
|
||||
// sheetSelectorFlagsForSubOp returns the (id, name) flag names a +batch-update
|
||||
// sub-op uses to express its placement / context sheet. Defaults are
|
||||
// `sheet-id` / `sheet-name`; +pivot-create deviates because its create
|
||||
// shortcut renamed the placement selector to `target-sheet-id` /
|
||||
// `target-sheet-name` (the data-source sheet is encoded in --source as
|
||||
// `'SheetName'!Range`, not in a sheet selector flag). Update / delete on
|
||||
// pivot still use the default names — only the create create-side
|
||||
// shortcut was renamed.
|
||||
func sheetSelectorFlagsForSubOp(shortcut string) (string, string) {
|
||||
if shortcut == "+pivot-create" {
|
||||
return "target-sheet-id", "target-sheet-name"
|
||||
}
|
||||
return "sheet-id", "sheet-name"
|
||||
}
|
||||
|
||||
// objCreateTranslate / objUpdateTranslate / objDeleteTranslate bind an object
|
||||
// CRUD spec to the shared object_crud builders.
|
||||
func objCreateTranslate(spec objectCRUDSpec) batchTranslateFn {
|
||||
return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
return objectCreateInput(fv, token, sheetID, sheetName, spec)
|
||||
}
|
||||
}
|
||||
|
||||
func objUpdateTranslate(spec objectCRUDSpec) batchTranslateFn {
|
||||
return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
return objectUpdateInput(fv, token, sheetID, sheetName, spec)
|
||||
}
|
||||
}
|
||||
|
||||
func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn {
|
||||
return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
return objectDeleteInput(fv, token, sheetID, sheetName, spec)
|
||||
}
|
||||
}
|
||||
|
||||
// batchOpDispatch covers every write shortcut that can join an atomic batch.
|
||||
// Each entry plugs the shortcut's standalone xxxInput builder into the
|
||||
// batch translator path — so the body is byte-identical to the standalone
|
||||
// invocation (locked by TestBatchOp_BodyMatchesStandalone) and the missing-
|
||||
// flag error is identical too (locked by TestBatchOp_ErrorEquivalence).
|
||||
var batchOpDispatch = map[string]batchOpMapping{
|
||||
// ─── 单元格内容 ──────────────────────────────────────────────────
|
||||
"+cells-set": {"set_cell_range", cellsSetInput},
|
||||
"+cells-set-style": {"set_cell_range", cellsSetStyleInput},
|
||||
"+cells-clear": {"clear_cell_range", cellsClearInput},
|
||||
"+cells-replace": {"replace_data", replaceInput},
|
||||
"+csv-put": {"set_range_from_csv", csvPutInput},
|
||||
"+dropdown-set": {"set_cell_range", dropdownSetInput},
|
||||
|
||||
// ─── 单元格合并 (merge_cells, operation 区分) ────────────────────
|
||||
"+cells-merge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return mergeInput(fv, token, sid, sname, "merge", true)
|
||||
}},
|
||||
"+cells-unmerge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return mergeInput(fv, token, sid, sname, "unmerge", false)
|
||||
}},
|
||||
|
||||
// ─── 行列结构 (modify_sheet_structure, operation 区分) ──────────
|
||||
"+dim-insert": {"modify_sheet_structure", dimInsertInput},
|
||||
"+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return dimRangeOpInput(fv, token, sid, sname, "delete")
|
||||
}},
|
||||
"+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return dimRangeOpInput(fv, token, sid, sname, "hide")
|
||||
}},
|
||||
"+dim-unhide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return dimRangeOpInput(fv, token, sid, sname, "unhide")
|
||||
}},
|
||||
"+dim-freeze": {"modify_sheet_structure", dimFreezeInput},
|
||||
"+dim-group": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return dimGroupInput(fv, token, sid, sname, "group")
|
||||
}},
|
||||
"+dim-ungroup": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return dimGroupInput(fv, token, sid, sname, "ungroup")
|
||||
}},
|
||||
|
||||
// ─── 行高列宽 (resize_range, 无 operation 字段) ─────────────────
|
||||
"+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return resizeInput(fv, token, sid, sname, "row")
|
||||
}},
|
||||
"+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return resizeInput(fv, token, sid, sname, "column")
|
||||
}},
|
||||
|
||||
// ─── 区域操作 (transform_range, operation 区分) ─────────────────
|
||||
"+range-move": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return transformMoveCopyInput(fv, token, sid, sname, "move", false)
|
||||
}},
|
||||
"+range-copy": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return transformMoveCopyInput(fv, token, sid, sname, "copy", true)
|
||||
}},
|
||||
"+range-fill": {"transform_range", rangeFillInput},
|
||||
"+range-sort": {"transform_range", rangeSortInput},
|
||||
|
||||
// ─── 工作簿 / 子表 (modify_workbook_structure, operation 区分) ──
|
||||
"+sheet-create": {"modify_workbook_structure", func(fv flagView, token, _, _ string) (map[string]interface{}, error) {
|
||||
return sheetCreateInput(fv, token)
|
||||
}},
|
||||
"+sheet-delete": {"modify_workbook_structure", sheetDeleteInput},
|
||||
"+sheet-rename": {"modify_workbook_structure", sheetRenameInput},
|
||||
"+sheet-move": {"modify_workbook_structure", sheetMoveBatchInput},
|
||||
"+sheet-copy": {"modify_workbook_structure", sheetCopyInput},
|
||||
"+sheet-hide": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "hide")
|
||||
}},
|
||||
"+sheet-unhide": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
|
||||
}},
|
||||
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
|
||||
|
||||
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
|
||||
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
|
||||
"+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)},
|
||||
"+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)},
|
||||
|
||||
"+pivot-create": {"manage_pivot_table_object", objCreateTranslate(pivotSpec)},
|
||||
"+pivot-update": {"manage_pivot_table_object", objUpdateTranslate(pivotSpec)},
|
||||
"+pivot-delete": {"manage_pivot_table_object", objDeleteTranslate(pivotSpec)},
|
||||
|
||||
"+cond-format-create": {"manage_conditional_format_object", objCreateTranslate(condFormatSpec)},
|
||||
"+cond-format-update": {"manage_conditional_format_object", objUpdateTranslate(condFormatSpec)},
|
||||
"+cond-format-delete": {"manage_conditional_format_object", objDeleteTranslate(condFormatSpec)},
|
||||
|
||||
"+filter-create": {"manage_filter_object", filterCreateInput},
|
||||
"+filter-update": {"manage_filter_object", filterUpdateInput},
|
||||
"+filter-delete": {"manage_filter_object", filterDeleteInput},
|
||||
|
||||
"+filter-view-create": {"manage_filter_view_object", objCreateTranslate(filterViewSpec)},
|
||||
"+filter-view-update": {"manage_filter_view_object", objUpdateTranslate(filterViewSpec)},
|
||||
"+filter-view-delete": {"manage_filter_view_object", objDeleteTranslate(filterViewSpec)},
|
||||
|
||||
"+sparkline-create": {"manage_sparkline_object", objCreateTranslate(sparklineSpec)},
|
||||
"+sparkline-update": {"manage_sparkline_object", objUpdateTranslate(sparklineSpec)},
|
||||
"+sparkline-delete": {"manage_sparkline_object", objDeleteTranslate(sparklineSpec)},
|
||||
|
||||
"+float-image-create": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
if err := rejectLocalImageInBatch(fv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return floatImageWriteInput(fv, token, sid, sname, "create", false, "")
|
||||
}},
|
||||
"+float-image-update": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
if err := rejectLocalImageInBatch(fv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return floatImageWriteInput(fv, token, sid, sname, "update", true, "")
|
||||
}},
|
||||
"+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)},
|
||||
}
|
||||
|
||||
// rejectLocalImageInBatch blocks the local-file --image source inside
|
||||
// +batch-update: a batch sub-op has no upload phase, so the file could not be
|
||||
// turned into a file_token. Callers must pass --image-token / --image-uri.
|
||||
func rejectLocalImageInBatch(fv flagView) error {
|
||||
if strings.TrimSpace(fv.Str("image")) != "" {
|
||||
return common.FlagErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sheetMoveBatchInput translates +sheet-move inside a batch. Unlike the
|
||||
// standalone shortcut it cannot issue the get_workbook_structure read that
|
||||
// auto-derives sheet_id / source_index, so both must be supplied explicitly.
|
||||
func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if sheetID == "" {
|
||||
return nil, common.FlagErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)")
|
||||
}
|
||||
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",
|
||||
"sheet_id": sheetID,
|
||||
"source_index": fv.Int("source-index"),
|
||||
"target_index": fv.Int("index"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// reservedSubOpKeys 是禁止用户在 sub-op input 里手填的 key —— 它们由
|
||||
// +batch-update 顶层 --url/--token 统一提供(excel_id / spreadsheet_token / url)。
|
||||
var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
|
||||
|
||||
// translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP
|
||||
// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用
|
||||
// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone
|
||||
// translator 翻成 MCP body。
|
||||
//
|
||||
// 失败场景:
|
||||
// - shortcut 字段缺失 / 非 string
|
||||
// - shortcut 不在 dispatch 表(拼写错;read 操作;嵌套 fan-out wrapper)
|
||||
// - input 不是 object
|
||||
// - input 里手填了 operation(由 shortcut 名隐含,禁手填以防 mismatch)
|
||||
// - input 里手填了 excel_id / spreadsheet_token / url
|
||||
// - 子操作的 translator 报错(如缺必填字段)
|
||||
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("operations[%d] must be a JSON object", index)
|
||||
}
|
||||
scRaw, present := op["shortcut"]
|
||||
if !present {
|
||||
return nil, common.FlagErrorf("operations[%d]: 'shortcut' field is required", index)
|
||||
}
|
||||
sc, ok := scRaw.(string)
|
||||
if !ok || sc == "" {
|
||||
return nil, common.FlagErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
|
||||
}
|
||||
mapping, ok := batchOpDispatch[sc]
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf(
|
||||
"operations[%d]: shortcut %q not allowed in +batch-update "+
|
||||
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+
|
||||
"run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)",
|
||||
index, sc,
|
||||
)
|
||||
}
|
||||
inputRaw, hasInput := op["input"]
|
||||
var input map[string]interface{}
|
||||
if !hasInput || inputRaw == nil {
|
||||
input = map[string]interface{}{}
|
||||
} else {
|
||||
input, ok = inputRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw)
|
||||
}
|
||||
}
|
||||
// 禁手填 operation —— 由 shortcut 名表达,手填易与 shortcut 不一致。
|
||||
if _, has := input["operation"]; has {
|
||||
return nil, common.FlagErrorf(
|
||||
"operations[%d] (%s): do not pass input.operation manually — it is implied by the shortcut name",
|
||||
index, sc,
|
||||
)
|
||||
}
|
||||
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
|
||||
for _, k := range reservedSubOpKeys {
|
||||
if _, has := input[k]; has {
|
||||
return nil, common.FlagErrorf(
|
||||
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
|
||||
index, sc, k,
|
||||
)
|
||||
}
|
||||
}
|
||||
// 拒绝任何额外的 sub-op 顶层 key(防御未来 schema drift / 用户笔误)。
|
||||
for k := range op {
|
||||
if k != "shortcut" && k != "input" {
|
||||
return nil, common.FlagErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
|
||||
}
|
||||
}
|
||||
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))
|
||||
body, err := mapping.translate(fv, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"tool_name": mapping.mcpToolName,
|
||||
"input": body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
||||
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
||||
if len(rawOps) == 0 {
|
||||
return nil, common.FlagErrorf("--operations must be a non-empty JSON array")
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
for i, raw := range rawOps {
|
||||
translated, err := translateBatchOp(raw, token, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, translated)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
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
|
||||
// first-try failure. csvPutInput now accepts --range as an alias for
|
||||
// --start-cell; a range value collapses to its top-left cell.
|
||||
func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw map[string]interface{}
|
||||
wantAnchor string
|
||||
}{
|
||||
{"start-cell direct (unchanged)", map[string]interface{}{"csv": "a,b", "start-cell": "B2"}, "B2"},
|
||||
{"range alias, single cell", map[string]interface{}{"csv": "a,b", "range": "B2"}, "B2"},
|
||||
{"range alias collapses to top-left", map[string]interface{}{"csv": "a,b", "range": "A1:H17"}, "A1"},
|
||||
{"start-cell wins when both set", map[string]interface{}{"csv": "a,b", "start-cell": "C3", "range": "A1:H17"}, "C3"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fv := newMapFlagViewForCommand("+csv-put", tt.raw)
|
||||
input, err := csvPutInput(fv, "tok", "sid", "")
|
||||
if err != nil {
|
||||
t.Fatalf("csvPutInput returned error: %v", err)
|
||||
}
|
||||
got, _ := input["start_cell"].(string)
|
||||
if got != tt.wantAnchor {
|
||||
t.Errorf("start_cell = %q, want %q", got, tt.wantAnchor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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"})
|
||||
_, err := csvPutInput(fv, "tok", "sid", "")
|
||||
if err == nil {
|
||||
t.Fatal("csvPutInput accepted missing start-cell/range; want a required-flag error")
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
// csvPutWriteRangeFromInput surfaces the real paste footprint so agents can see
|
||||
// how far a CSV reaches from its anchor — it auto-expands to the CSV's own size,
|
||||
// not to any user-set range.
|
||||
func TestCsvPutWriteRangeFromInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"3x3 at B2", map[string]interface{}{"start_cell": "B2", "csv": "a,b,c\n1,2,3\n4,5,6"}, "B2:D4", true},
|
||||
{"single cell at A1", map[string]interface{}{"start_cell": "A1", "csv": "x"}, "A1:A1", true},
|
||||
{"1 row 3 cols at C3", map[string]interface{}{"start_cell": "C3", "csv": "a,b,c"}, "C3:E3", true},
|
||||
{"ragged rows use max width", map[string]interface{}{"start_cell": "A1", "csv": "a,b\nc,d,e"}, "A1:C2", true},
|
||||
{"missing csv", map[string]interface{}{"start_cell": "A1"}, "", false},
|
||||
{"non-single anchor", map[string]interface{}{"start_cell": "A1:B2", "csv": "x"}, "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := csvPutWriteRangeFromInput(tt.input)
|
||||
if ok != tt.ok || got != tt.want {
|
||||
t.Errorf("got (%q, %v), want (%q, %v)", got, ok, tt.want, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,578 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
|
||||
// verifies the shortcut decodes the JSON-string output, surfaces it as
|
||||
// envelope data, and finishes without error.
|
||||
func TestExecute_WorkbookInfo_Happy(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"Sheet1","row_count":1000,"column_count":26,"index":0}]}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookInfo, []string{"--url", testURL}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
if len(sheets) != 1 {
|
||||
t.Fatalf("sheets len = %d, want 1", len(sheets))
|
||||
}
|
||||
sheet, _ := sheets[0].(map[string]interface{})
|
||||
if sheet["sheet_id"] != "sh1" || sheet["title"] != "Sheet1" {
|
||||
t.Errorf("unexpected sheet: %#v", sheet)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookInfo_ToolError surfaces a non-zero code in the
|
||||
// envelope shape and asserts CLI returns an error envelope.
|
||||
func TestExecute_WorkbookInfo_ToolError(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1310201,
|
||||
"msg": "spreadsheet not found",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
stdout, stderr, err := func() (string, string, error) {
|
||||
parent, stdout, stderr, reg := newTestRig(t, WorkbookInfo)
|
||||
reg.Register(stub)
|
||||
parent.SetArgs([]string{"+workbook-info", "--url", testURL})
|
||||
err := parent.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}()
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-zero code to surface as error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "1310201") && !strings.Contains(combined, "not found") {
|
||||
t.Errorf("expected error code in envelope; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_SheetMove_LookupsIndex covers the two-step path: SheetMove
|
||||
// when only --sheet-name is given (and --source-index omitted) first
|
||||
// reads the workbook structure to derive sheet_id + source_index, then
|
||||
// posts the modify_workbook_structure call.
|
||||
func TestExecute_SheetMove_LookupsIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","sheet_name":"汇总","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)
|
||||
}
|
||||
// Inspect the captured move body: source_index should be 3 (looked up),
|
||||
// not <resolve>, and sheet_id should be the resolved id.
|
||||
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)", input["sheet_id"])
|
||||
}
|
||||
if input["source_index"].(float64) != 3 {
|
||||
t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"])
|
||||
}
|
||||
if input["target_index"].(float64) != 0 {
|
||||
t.Errorf("target_index = %v, want 0", input["target_index"])
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
stub := toolOutputStub(testToken, "read", `{"ranges":[{"range":"A1:B2","cells":[[{"value":1}]]}]}`)
|
||||
out, err := runShortcutWithStubs(t, CellsGet,
|
||||
[]string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2"}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
if data := decodeEnvelopeData(t, out); data["ranges"] == nil {
|
||||
t.Fatalf("expected ranges in output; got=%#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_CellsSet covers the write path including allow-overwrite
|
||||
// override.
|
||||
func TestExecute_CellsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"updated_cells":2}`)
|
||||
out, err := runShortcutWithStubs(t, CellsSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:B1",
|
||||
"--cells", `[[{"value":"x"},{"value":"y"}]]`,
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["range"] != "A1:B1" {
|
||||
t.Errorf("wire range = %v", input["range"])
|
||||
}
|
||||
if data := decodeEnvelopeData(t, out); data["updated_cells"].(float64) != 2 {
|
||||
t.Errorf("updated_cells = %v", data["updated_cells"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_DropdownSet covers the fan-out → set_cell_range write.
|
||||
func TestExecute_DropdownSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{}`)
|
||||
_, err := runShortcutWithStubs(t, DropdownSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A2:A4",
|
||||
"--options", `["x","y"]`,
|
||||
"--multiple",
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) != 3 {
|
||||
t.Errorf("wire cells rows = %d, want 3", len(cells))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_DropdownUpdate_Batch covers the batch_update fan-out for
|
||||
// dropdown-update. Verifies the captured request has 2 ops.
|
||||
func TestExecute_DropdownUpdate_Batch(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true},{"ok":true}]}`)
|
||||
_, err := runShortcutWithStubs(t, DropdownUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`,
|
||||
"--options", `["a","b"]`,
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 2 {
|
||||
t.Errorf("operations len = %d, want 2", len(ops))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_CellsSearch covers the search read path with options.
|
||||
func TestExecute_CellsSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "read", `{"matches":[{"cell":"B2"}],"has_more":false}`)
|
||||
out, err := runShortcutWithStubs(t, CellsSearch, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--find", "foo", "--match-case",
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["matches"] == nil {
|
||||
t.Errorf("matches missing: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_RangeMove covers the transform_range write path.
|
||||
func TestExecute_RangeMove(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"moved":true}`)
|
||||
out, err := runShortcutWithStubs(t, RangeMove, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--source-range", "A1:C5",
|
||||
"--target-range", "D1",
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "transform_range")
|
||||
if input["operation"] != "move" {
|
||||
t.Errorf("operation = %v, want move", input["operation"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_FilterCreate covers the filter special case (range mandatory,
|
||||
// optional --data conditions merge).
|
||||
func TestExecute_FilterCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"filter_id":"sh1"}`)
|
||||
out, err := runShortcutWithStubs(t, FilterCreate, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:F100",
|
||||
"--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["x"]}]}]}`,
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "manage_filter_object")
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if props["range"] != "A1:F100" {
|
||||
t.Errorf("properties.range = %v", props["range"])
|
||||
}
|
||||
if props["rules"] == nil {
|
||||
t.Errorf("rules missing: %#v", props)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_BatchUpdate_Translated covers the CLI-shape → MCP-shape
|
||||
// translation: user passes {shortcut, input}, batchOpDispatch maps it to
|
||||
// {tool_name, input(+operation, +excel_id)} before the tool call. Also
|
||||
// verifies --continue-on-error.
|
||||
func TestExecute_BatchUpdate_Translated(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
|
||||
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}]`,
|
||||
"--continue-on-error",
|
||||
"--yes",
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
if input["continue_on_error"] != true {
|
||||
t.Errorf("continue_on_error not propagated: %#v", input)
|
||||
}
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("operations length = %d, want 1", len(ops))
|
||||
}
|
||||
op := ops[0].(map[string]interface{})
|
||||
if op["tool_name"] != "set_cell_range" {
|
||||
t.Errorf("op.tool_name = %v, want set_cell_range (translated from +cells-set)", op["tool_name"])
|
||||
}
|
||||
subInput, _ := op["input"].(map[string]interface{})
|
||||
if subInput["excel_id"] != testToken {
|
||||
t.Errorf("op.input.excel_id = %v, want %s (translator should inject)", subInput["excel_id"], testToken)
|
||||
}
|
||||
if _, has := subInput["operation"]; has {
|
||||
t.Errorf("op.input.operation present but +cells-set should not inject one: %#v", subInput)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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": "shtcnBRAND",
|
||||
"title": "Sales",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Initial fill first reads the workbook structure to resolve the default
|
||||
// sheet's id (the create response doesn't echo it), then writes.
|
||||
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
|
||||
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95]]`,
|
||||
}, create, structure, fill)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
ss, _ := data["spreadsheet"].(map[string]interface{})
|
||||
if ss["spreadsheet_token"] != "shtcnBRAND" {
|
||||
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
|
||||
}
|
||||
if data["initial_fill"] == nil {
|
||||
t.Errorf("initial_fill missing in envelope")
|
||||
}
|
||||
// The fill must target the resolved first sheet, not an empty selector.
|
||||
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
|
||||
if fillInput["sheet_id"] != "shtFirst" {
|
||||
t.Errorf("fill sheet_id = %v, want shtFirst (resolved from workbook structure)", fillInput["sheet_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// parsed into destination_index=10.
|
||||
func TestExecute_DimMove(t *testing.T) {
|
||||
t.Parallel()
|
||||
move := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets/" + testToken + "/sheets/" + testSheetID + "/move_dimension",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"moved": true},
|
||||
},
|
||||
}
|
||||
_, err := runShortcutWithStubs(t, DimMove, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--source-range", "1:3", "--target", "11",
|
||||
}, move)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, move.CapturedBody)
|
||||
src, _ := body["source"].(map[string]interface{})
|
||||
if src["start_index"].(float64) != 0 || src["end_index"].(float64) != 2 {
|
||||
t.Errorf("indices = (%v,%v), want (0,2) — 0-based inclusive", src["start_index"], src["end_index"])
|
||||
}
|
||||
if body["destination_index"].(float64) != 10 {
|
||||
t.Errorf("destination_index = %v, want 10", body["destination_index"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_ChartCreate covers the object-CRUD factory's create path.
|
||||
func TestExecute_ChartCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"chart_id":"chartNEW"}`)
|
||||
out, err := runShortcutWithStubs(t, ChartCreate, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`,
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["chart_id"] != "chartNEW" {
|
||||
t.Errorf("chart_id = %v", data["chart_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_SheetCreate hits the workbook write path with all four
|
||||
// optional flags so the input builder + callTool wiring is exercised.
|
||||
func TestExecute_SheetCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"sheet_id":"sh99","sheet_name":"Q4","index":2}`)
|
||||
out, err := runShortcutWithStubs(t, SheetCreate, []string{
|
||||
"--url", testURL,
|
||||
"--title", "Q4",
|
||||
"--index", "2",
|
||||
"--row-count", "300",
|
||||
"--col-count", "12",
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "modify_workbook_structure")
|
||||
if input["operation"] != "create" || input["sheet_name"] != "Q4" {
|
||||
t.Errorf("input shape wrong: %#v", input)
|
||||
}
|
||||
if input["rows"].(float64) != 300 || input["columns"].(float64) != 12 {
|
||||
t.Errorf("dimensions = (%v, %v), want (300, 12)", input["rows"], input["columns"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_RangeSort exercises the sort_conditions JSON parsing
|
||||
// alongside the boolean has_header.
|
||||
func TestExecute_RangeSort(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"sorted":true}`)
|
||||
_, err := runShortcutWithStubs(t, RangeSort, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:D50",
|
||||
"--has-header",
|
||||
"--sort-keys", `[{"column":"B","ascending":true}]`,
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "transform_range")
|
||||
if input["operation"] != "sort" || input["has_header"] != true {
|
||||
t.Errorf("input wrong: %#v", input)
|
||||
}
|
||||
conds, _ := input["sort_conditions"].([]interface{})
|
||||
if len(conds) != 1 {
|
||||
t.Errorf("sort_conditions len = %d", len(conds))
|
||||
}
|
||||
}
|
||||
|
||||
// decodeRawEnvelopeBody parses the raw JSON request body captured by an
|
||||
// httpmock stub. Used by execute tests to inspect what the CLI sent on
|
||||
// the wire (vs. dry-run tests that render the body up-front).
|
||||
func decodeRawEnvelopeBody(t *testing.T, raw []byte) map[string]interface{} {
|
||||
t.Helper()
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("captured body parse error: %v\nraw=%s", err, string(raw))
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── flag definitions, sourced from sheet-skill-spec ───────────────────
|
||||
//
|
||||
// data/flag-defs.json is the canonical, full definition of every CLI flag
|
||||
// (name, type, default, desc, enum, input, hidden, required, kind),
|
||||
// generated by sheet-skill-spec's sync script. The sync script also emits
|
||||
// flag_defs_gen.go — the compiled `flagDefs` map — so command startup pays
|
||||
// no JSON unmarshal (the parse cost used to land on every CLI invocation,
|
||||
// sheets or not). We build each shortcut's []common.Flag from flagDefs at
|
||||
// assembly time, so flag metadata never has to be hand-written in Go.
|
||||
//
|
||||
// Flags with kind == "system" (--dry-run, --yes, ...) are NOT materialized
|
||||
// here: the framework auto-injects them based on Risk / DryRun / HasFormat.
|
||||
// Do not hand-edit flag_defs_gen.go or data/flag-defs.json; regenerate via
|
||||
// the sync script. flag_defs_gen_test.go guards the two against drift.
|
||||
|
||||
type flagDef struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // "public" | "own" | "system"
|
||||
Type string `json:"type"` // string | bool | int | int64 | float64 | string_array | string_slice
|
||||
Required string `json:"required"` // "required" | "optional" | "xor"
|
||||
Desc string `json:"desc"`
|
||||
Default string `json:"default"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Enum []string `json:"enum"`
|
||||
Input []string `json:"input"`
|
||||
}
|
||||
|
||||
type commandDef struct {
|
||||
Risk string `json:"risk"`
|
||||
Flags []flagDef `json:"flags"`
|
||||
}
|
||||
|
||||
// loadFlagDefs returns the compiled flag definitions (flag_defs_gen.go).
|
||||
// The error return is always nil; it is retained so existing call sites that
|
||||
// handled a parse error keep compiling. There is no longer a runtime parse.
|
||||
func loadFlagDefs() (map[string]commandDef, error) {
|
||||
return flagDefs, nil
|
||||
}
|
||||
|
||||
// flagsFor builds the []common.Flag for a shortcut command directly from
|
||||
// flag-defs.json. System-kind flags are skipped (the framework injects
|
||||
// them). Panics if the command is absent or the JSON is malformed — this
|
||||
// is a build-time data contract, so a missing entry is a programming error
|
||||
// surfaced loudly at startup rather than a silent empty flag set.
|
||||
func flagsFor(command string) []common.Flag {
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("sheets: %v", err))
|
||||
}
|
||||
spec, ok := defs[command]
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("sheets: no flag-defs.json entry for %q", command))
|
||||
}
|
||||
out := make([]common.Flag, 0, len(spec.Flags))
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" {
|
||||
continue
|
||||
}
|
||||
out = append(out, common.Flag{
|
||||
Name: df.Name,
|
||||
Type: df.Type,
|
||||
Default: df.Default,
|
||||
Desc: df.Desc,
|
||||
Hidden: df.Hidden,
|
||||
Required: df.Required == "required",
|
||||
Enum: df.Enum,
|
||||
Input: df.Input,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,927 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Code generated from data/flag-defs.json; DO NOT EDIT.
|
||||
|
||||
package sheets
|
||||
|
||||
// flagDefs is the compiled form of data/flag-defs.json — every CLI flag's
|
||||
// metadata for every shortcut, emitted as a Go literal so command startup
|
||||
// pays no JSON unmarshal (see flag_defs.go). Do not hand-edit; regenerate
|
||||
// with `go generate ./shortcuts/sheets/...` after data/flag-defs.json
|
||||
// changes.
|
||||
var flagDefs = map[string]commandDef{
|
||||
"+batch-update": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}},
|
||||
{Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"},
|
||||
},
|
||||
},
|
||||
"+cells-batch-clear": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-batch-set-style": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||
{Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}},
|
||||
{Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}},
|
||||
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
|
||||
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
|
||||
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-clear": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to clear (A1 notation)"},
|
||||
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); clear is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-merge": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
|
||||
{Name: "merge-type", Kind: "own", Type: "string", Required: "optional", Desc: "Merge direction (`+cells-merge` only)", Default: "all", Enum: []string{"all", "rows", "columns"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-replace": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find for replacement"},
|
||||
{Name: "replacement", Kind: "own", Type: "string", Required: "required", Desc: "Replacement text; pass empty string `\"\"` to delete matched content"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Replace range (A1 notation); whole sheet when omitted"},
|
||||
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
|
||||
{Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"},
|
||||
{Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"},
|
||||
{Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also replace within formula text"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Required preflight: outputs `would_replace_count` for user confirmation before the actual replace"},
|
||||
},
|
||||
},
|
||||
"+cells-search": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find (interpreted as regex when `--regex` is set)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Search range (A1 notation); whole sheet when omitted"},
|
||||
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
|
||||
{Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"},
|
||||
{Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"},
|
||||
{Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also search within formula text"},
|
||||
{Name: "max-matches", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 5000", Default: "5000", Hidden: true},
|
||||
{Name: "offset", Kind: "own", Type: "int", Required: "optional", Desc: "Skip the first N matches (for pagination); default 0", Default: "0"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-set": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"},
|
||||
{Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true},
|
||||
{Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-set-image": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)"},
|
||||
{Name: "image", Kind: "own", Type: "string", Required: "required", Desc: "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)"},
|
||||
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Image file name (with extension); defaults to the basename of `--image`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-set-style": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||
{Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}},
|
||||
{Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}},
|
||||
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
|
||||
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
|
||||
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-unmerge": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+chart-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||
},
|
||||
},
|
||||
"+chart-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+chart-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter to a single chart reference_id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+chart-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cols-resize": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", Enum: []string{"pixel", "standard"}},
|
||||
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", Default: "0"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cond-format-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cond-format-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cond-format-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by rule id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cond-format-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", Input: []string{"file", "stdin"}},
|
||||
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+csv-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
},
|
||||
},
|
||||
"+csv-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-freeze": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dimension", Kind: "own", Type: "string", Required: "required", Desc: "Dimension (row or column)", Enum: []string{"row", "column"}},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Freeze the first N rows/columns; pass 0 to unfreeze"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-group": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Nesting level for grouping; default 1", Default: "1"},
|
||||
{Name: "group-state", Kind: "own", Type: "string", Required: "optional", Desc: "Initial group expand state", Default: "expand", Enum: []string{"expand", "fold"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-hide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-insert": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}},
|
||||
{Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-move": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "target", Kind: "own", Type: "string", Required: "required", Desc: "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-ungroup": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-unhide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dropdown-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dropdown-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range in A1 notation, e.g. `A2:A100` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dropdown-set": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A2:A100`)"},
|
||||
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select; default `false`"},
|
||||
{Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dropdown-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
|
||||
{Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "optional", Desc: "Filter rule JSON: `rules` (per-column rule array), `filtered_columns?` (active column index hint). The flag is optional overall — if provided, `rules` must be non-empty; if omitted, an empty filter is created on `--range` (no column conditions). `range` is a separate flag (do not duplicate inside this JSON)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-view-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-view-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-view-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by filter-view reference_id (returns the matching single view)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-view-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?`, `filtered_columns?`; update overwrites the entire rule set (read back with `+filter-view-list` first, then patch; pass `rules: []` to clear). `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+float-image-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
|
||||
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
|
||||
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
|
||||
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
|
||||
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
|
||||
{Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"},
|
||||
{Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"},
|
||||
{Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"},
|
||||
{Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"},
|
||||
{Name: "image", Kind: "own", Type: "string", Required: "xor", Desc: "Local image path; the CLI uploads it as a sheet_image and uses the returned file_token (XOR with --image-token / --image-uri)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+float-image-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+float-image-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id; lists all float images on the sheet when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+float-image-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
|
||||
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
|
||||
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
|
||||
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
|
||||
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
|
||||
{Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"},
|
||||
{Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"},
|
||||
{Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"},
|
||||
{Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: {\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true} (data source goes through --source; do not put source here)", Input: []string{"file", "stdin"}},
|
||||
{Name: "target-position", Kind: "own", Type: "string", Required: "optional", Desc: "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", Default: "A1"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "xor", Desc: "Reference_id of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-name`; takes priority when both given; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."},
|
||||
{Name: "target-sheet-name", Kind: "own", Type: "string", Required: "xor", Desc: "Name of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-id`; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."},
|
||||
{Name: "source", Kind: "own", Type: "string", Required: "required", Desc: "Pivot table source range (A1 notation; format `'SheetName'!StartCell:EndCell`, e.g. `'Sheet1'!A1:D100`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Pivot table top-left placement (single A1 value, e.g. `F1`; create only), maps to `properties.range`; placed at the top-left of the target sub-sheet (a newly created one by default) when omitted. It and `--target-position` both express placement but map to different wire fields — avoid passing conflicting values for both."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id <id>` first, then patch)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+range-copy": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
|
||||
{Name: "paste-type", Kind: "own", Type: "string", Required: "optional", Desc: "Paste content type (`+range-copy` only)", Default: "all", Enum: []string{"values", "formulas", "formats", "all"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+range-fill": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Fill template range (seed cells for the series)"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination fill range (A1 notation)"},
|
||||
{Name: "series-type", Kind: "own", Type: "string", Required: "optional", Desc: "Fill series type", Default: "auto", Enum: []string{"auto", "linear", "growth", "date", "copy"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+range-move": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+range-sort": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Sort range (A1 notation; whether the header is included depends on `--has-header`)"},
|
||||
{Name: "sort-keys", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: `[{\"column\":\"<col letter>\",\"ascending\":<bool>}, ...]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "has-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as a header and exclude from sort; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+rows-resize": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", Enum: []string{"pixel", "standard", "auto"}},
|
||||
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", Default: "0"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-copy": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Copy title; auto-generated by the server when omitted"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position for the copy (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
|
||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-hide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated structure info categories to return", Enum: []string{"merges", "row_heights", "col_widths", "hidden_rows", "hidden_cols", "groups", "frozen"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Limit structure info to this A1 range; whole sheet when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-move": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
|
||||
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-rename": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New title"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-set-tab-color": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "color", Kind: "own", Type: "string", Required: "required", Desc: "Hex color like `#FF0000`; pass empty string `\"\"` to clear"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-unhide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sparkline-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config (shared style), sparklines (array of mini-charts)}`; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sparkline-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sparkline-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by group_id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sparkline-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config, sparklines}`; read back with `+sparkline-list --group-id <id>` first, then patch; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-export": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "file-extension", Kind: "own", Type: "string", Required: "optional", Desc: "Export file format; `csv` mode requires `--sheet-id`", Default: "xlsx", Enum: []string{"xlsx", "csv"}},
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path; export is triggered but not downloaded when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// flagDefsJSONForTest embeds the source data only in tests; production code
|
||||
// reads the compiled flagDefs map (flag_defs_gen.go) and never unmarshals.
|
||||
//
|
||||
//go:embed data/flag-defs.json
|
||||
var flagDefsJSONForTest []byte
|
||||
|
||||
// TestFlagDefsGen_MatchesJSON guards against drift between the compiled
|
||||
// flagDefs map (flag_defs_gen.go) and its source data/flag-defs.json: if the
|
||||
// JSON is regenerated without re-running the codegen (or vice versa), this
|
||||
// fails. This equivalence is exactly what lets production code skip the
|
||||
// runtime unmarshal.
|
||||
func TestFlagDefsGen_MatchesJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
var fromJSON map[string]commandDef
|
||||
if err := json.Unmarshal(flagDefsJSONForTest, &fromJSON); err != nil {
|
||||
t.Fatalf("unmarshal flag-defs.json: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(fromJSON, flagDefs) {
|
||||
t.Error("compiled flagDefs differs from data/flag-defs.json; regenerate flag_defs_gen.go")
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestFlagDefs_EmbedParses asserts the embedded flag-defs.json blob is valid
|
||||
// JSON with at least one command entry.
|
||||
func TestFlagDefs_EmbedParses(t *testing.T) {
|
||||
t.Parallel()
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagDefs error: %v", err)
|
||||
}
|
||||
if len(defs) == 0 {
|
||||
t.Fatal("flag-defs.json has no command entries")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagsFor_SkipsSystemFlags verifies system-kind flags (--dry-run, --yes)
|
||||
// are never materialized into a shortcut's Flags slice — the framework injects
|
||||
// those based on Risk / DryRun.
|
||||
func TestFlagsFor_SkipsSystemFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, cmd := range []string{"+sheet-delete", "+batch-update", "+csv-get"} {
|
||||
for _, f := range flagsFor(cmd) {
|
||||
if f.Name == "dry-run" || f.Name == "yes" {
|
||||
t.Errorf("%s: system flag --%s leaked into Flags", cmd, f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagsFor_MapsAllFields spot-checks that name/type/default/enum/input/
|
||||
// required/hidden are carried over from the JSON correctly.
|
||||
func TestFlagsFor_MapsAllFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
byName := func(cmd, name string) *common.Flag {
|
||||
flags := flagsFor(cmd)
|
||||
for i := range flags {
|
||||
if flags[i].Name == name {
|
||||
return &flags[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enum + default
|
||||
rt := byName("+dim-insert", "inherit-style")
|
||||
if rt == nil || len(rt.Enum) != 3 || rt.Default != "none" {
|
||||
t.Errorf("+dim-insert --inherit-style not mapped: %+v", rt)
|
||||
}
|
||||
// required
|
||||
title := byName("+sheet-create", "title")
|
||||
if title == nil || !title.Required {
|
||||
t.Errorf("+sheet-create --title should be required: %+v", title)
|
||||
}
|
||||
// xor is NOT cobra-required (enforced by Validate hooks)
|
||||
url := byName("+sheet-create", "url")
|
||||
if url == nil || url.Required {
|
||||
t.Errorf("+sheet-create --url should not be cobra-required: %+v", url)
|
||||
}
|
||||
// hidden + int default
|
||||
cap := byName("+cells-get", "max-chars")
|
||||
if cap == nil || !cap.Hidden || cap.Default != "200000" {
|
||||
t.Errorf("+cells-get --max-chars not mapped: %+v", cap)
|
||||
}
|
||||
// input sources
|
||||
cells := byName("+cells-set", "cells")
|
||||
if cells == nil || len(cells.Input) != 2 {
|
||||
t.Errorf("+cells-set --cells should support file+stdin: %+v", cells)
|
||||
}
|
||||
// float64 type
|
||||
fs := byName("+cells-set-style", "font-size")
|
||||
if fs == nil || fs.Type != "float64" {
|
||||
t.Errorf("+cells-set-style --font-size should be float64: %+v", fs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagsFor_EveryRegisteredCommandHasDefs ensures every shortcut returned by
|
||||
// Shortcuts() has a flag-defs.json entry and that its flags match the JSON's
|
||||
// non-system flags exactly (name + type + required + default + hidden). This is
|
||||
// the contract that lets shortcuts drop hand-written flag literals.
|
||||
func TestFlagsFor_EveryRegisteredCommandHasDefs(t *testing.T) {
|
||||
t.Parallel()
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range Shortcuts() {
|
||||
spec, ok := defs[s.Command]
|
||||
if !ok {
|
||||
t.Errorf("%s has no flag-defs.json entry", s.Command)
|
||||
continue
|
||||
}
|
||||
want := map[string]flagDef{}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind != "system" {
|
||||
want[df.Name] = df
|
||||
}
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, f := range s.Flags {
|
||||
got[f.Name] = true
|
||||
df, ok := want[f.Name]
|
||||
if !ok {
|
||||
t.Errorf("%s --%s present in Go but not in JSON (non-system)", s.Command, f.Name)
|
||||
continue
|
||||
}
|
||||
ft := f.Type
|
||||
if ft == "" {
|
||||
ft = "string"
|
||||
}
|
||||
jt := df.Type
|
||||
if jt == "" {
|
||||
jt = "string"
|
||||
}
|
||||
if ft != jt {
|
||||
t.Errorf("%s --%s type: go=%s json=%s", s.Command, f.Name, ft, jt)
|
||||
}
|
||||
if f.Required != (df.Required == "required") {
|
||||
t.Errorf("%s --%s required: go=%v json=%s", s.Command, f.Name, f.Required, df.Required)
|
||||
}
|
||||
if f.Default != df.Default {
|
||||
t.Errorf("%s --%s default: go=%q json=%q", s.Command, f.Name, f.Default, df.Default)
|
||||
}
|
||||
if f.Hidden != df.Hidden {
|
||||
t.Errorf("%s --%s hidden: go=%v json=%v", s.Command, f.Name, f.Hidden, df.Hidden)
|
||||
}
|
||||
}
|
||||
for name := range want {
|
||||
if !got[name] {
|
||||
t.Errorf("%s --%s in JSON but missing from Go Flags", s.Command, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ─── --print-schema runtime introspection ─────────────────────────────
|
||||
//
|
||||
// Composite JSON flags (--cells, --properties, --operations, --border-styles,
|
||||
// --sort-keys) carry non-trivial structured payloads. Reference docs cover
|
||||
// the top-level fields but agents often need the full JSON Schema to
|
||||
// generate valid input.
|
||||
//
|
||||
// To serve that need without forcing every caller to fetch external docs,
|
||||
// the spec repo ships a compact `flag-schemas.json` that extracts just the
|
||||
// schema subtree corresponding to each (shortcut, flag) pair. We embed
|
||||
// that artifact at compile time so `lark-cli sheets <shortcut>
|
||||
// --print-schema --flag-name <name>` runs entirely locally.
|
||||
//
|
||||
// The artifact is generated by sheet-skill-spec's
|
||||
// scripts/sync_to_consumers.mjs from canonical-spec/cli-flag-schema-map.json
|
||||
// + tool-schemas/mcp-tools.json. Do not hand-edit data/flag-schemas.json;
|
||||
// regenerate via the sync script.
|
||||
|
||||
//go:embed data/flag-schemas.json
|
||||
var flagSchemasJSON []byte
|
||||
|
||||
// flagSchemaIndex parses lazily on first access; failures are surfaced
|
||||
// as errors from the lookup helper rather than panicking at init time.
|
||||
type flagSchemaIndex struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Flags map[string]map[string]json.RawMessage `json:"flags"`
|
||||
}
|
||||
|
||||
// loadFlagSchemas is sync.Once-guarded so concurrent first access from
|
||||
// parallel goroutines (e.g. parallel unit tests, parallel shortcut
|
||||
// invocations) doesn't race on the lazy parse.
|
||||
var (
|
||||
flagSchemasOnce sync.Once
|
||||
parsedFlagSchemas *flagSchemaIndex
|
||||
parseFlagErr error
|
||||
)
|
||||
|
||||
func loadFlagSchemas() (*flagSchemaIndex, error) {
|
||||
flagSchemasOnce.Do(func() {
|
||||
var idx flagSchemaIndex
|
||||
if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil {
|
||||
parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err)
|
||||
return
|
||||
}
|
||||
if idx.Flags == nil {
|
||||
idx.Flags = map[string]map[string]json.RawMessage{}
|
||||
}
|
||||
parsedFlagSchemas = &idx
|
||||
})
|
||||
return parsedFlagSchemas, parseFlagErr
|
||||
}
|
||||
|
||||
// commandsWithFlagSchema returns the set of shortcut commands that have
|
||||
// at least one introspectable flag. Used by Shortcuts() to decide which
|
||||
// shortcuts to wire PrintFlagSchema into.
|
||||
func commandsWithFlagSchema() map[string]struct{} {
|
||||
idx, err := loadFlagSchemas()
|
||||
if err != nil || idx == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]struct{}, len(idx.Flags))
|
||||
for cmd := range idx.Flags {
|
||||
out[cmd] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// printFlagSchemaFor returns a PrintFlagSchema closure bound to the given
|
||||
// shortcut command. When flagName == "" the closure returns a JSON
|
||||
// listing of introspectable flags; otherwise it returns the schema
|
||||
// subtree JSON for the named flag, or an error if the flag is not
|
||||
// registered.
|
||||
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
return func(flagName string) ([]byte, error) {
|
||||
idx, err := loadFlagSchemas()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, ok := idx.Flags[command]
|
||||
if !ok || len(entry) == 0 {
|
||||
return nil, fmt.Errorf("no JSON Schema registered for %s", command)
|
||||
}
|
||||
if flagName == "" {
|
||||
flags := make([]string, 0, len(entry))
|
||||
for f := range entry {
|
||||
flags = append(flags, f)
|
||||
}
|
||||
sort.Strings(flags)
|
||||
return json.MarshalIndent(map[string]interface{}{
|
||||
"shortcut": command,
|
||||
"introspectable_flags": flags,
|
||||
"hint": "run again with --flag-name <name> to dump the JSON Schema for that flag",
|
||||
}, "", " ")
|
||||
}
|
||||
schema, ok := entry[flagName]
|
||||
if !ok {
|
||||
flags := make([]string, 0, len(entry))
|
||||
for f := range entry {
|
||||
flags = append(flags, f)
|
||||
}
|
||||
sort.Strings(flags)
|
||||
return nil, fmt.Errorf("no JSON Schema registered for %s --%s; available: %v", command, flagName, flags)
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
var pretty interface{}
|
||||
if err := json.Unmarshal(schema, &pretty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.MarshalIndent(pretty, "", " ")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user