mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
Compare commits
107 Commits
feat/sidec
...
feat/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ec690117d | ||
|
|
a8b29a1cf1 | ||
|
|
9c7d5a4b96 | ||
|
|
ce9764ec2e | ||
|
|
fc6e60ba5b | ||
|
|
5aba007f57 | ||
|
|
5c22f912aa | ||
|
|
7dd479df12 | ||
|
|
46066de29e | ||
|
|
e58fa13716 | ||
|
|
f99b5bf32e | ||
|
|
74761a0e1c | ||
|
|
7d24e2b649 | ||
|
|
e3eca666fb | ||
|
|
da65e37647 | ||
|
|
dce617eab2 | ||
|
|
9c3d30aa00 | ||
|
|
690e746896 | ||
|
|
0f695b60ec | ||
|
|
77f86ec2fd | ||
|
|
8e84f47d3e | ||
|
|
5ebb1398e7 | ||
|
|
0476dec83c | ||
|
|
69d2851163 | ||
|
|
ce5878e3c4 | ||
|
|
e85afd68d2 | ||
|
|
a09593a0fe | ||
|
|
60c61d8157 | ||
|
|
08e8b5c870 | ||
|
|
338cdaa6db | ||
|
|
55ccbc5f6a | ||
|
|
bea4c746ae | ||
|
|
4b16fe9ce0 | ||
|
|
f53e55ce65 | ||
|
|
71eae77f65 | ||
|
|
08d025945e | ||
|
|
930c9c77a8 | ||
|
|
bb7ccaedf9 | ||
|
|
a0d6472e9f | ||
|
|
6b3c0b5556 | ||
|
|
f4bcb85d2e | ||
|
|
5880d070e2 | ||
|
|
2082095f18 | ||
|
|
6cadbe807a | ||
|
|
927a73faa2 | ||
|
|
9c447e735b | ||
|
|
12b94746bb | ||
|
|
5327e9390d | ||
|
|
dece428487 | ||
|
|
ff493d4534 | ||
|
|
ff78ff40d8 | ||
|
|
1a2d2d04be | ||
|
|
5eaa70b74a | ||
|
|
f0dea38aeb | ||
|
|
fa503fa47f | ||
|
|
38ef6ad51e | ||
|
|
f0d218f7ea | ||
|
|
09c02e8657 | ||
|
|
2ee2a59dff | ||
|
|
96c338735a | ||
|
|
101c572d64 | ||
|
|
9d06652aa9 | ||
|
|
5926e89ce3 | ||
|
|
556b2292c7 | ||
|
|
4be06c85f6 | ||
|
|
868beaf004 | ||
|
|
81bb61359d | ||
|
|
370137e1c3 | ||
|
|
48e6072342 | ||
|
|
b85311c873 | ||
|
|
2f5c625ac7 | ||
|
|
e0c22d6ee0 | ||
|
|
efcc55460b | ||
|
|
e003d4aa01 | ||
|
|
d91341bca3 | ||
|
|
5a42fb5788 | ||
|
|
d914c851ac | ||
|
|
300a5e8906 | ||
|
|
5f3e8c6385 | ||
|
|
8e8a5110ee | ||
|
|
4e44e668f7 | ||
|
|
8d0fefd9e0 | ||
|
|
1e05e7b3ad | ||
|
|
0ea7c14e4a | ||
|
|
0c2e5f5e5c | ||
|
|
9048c7097f | ||
|
|
3d3e2c7f10 | ||
|
|
ce852e26d8 | ||
|
|
460c794f28 | ||
|
|
54914e6082 | ||
|
|
50190e8638 | ||
|
|
184949ff0c | ||
|
|
347d80361d | ||
|
|
be31975f7e | ||
|
|
2acff2b17f | ||
|
|
b9a1752095 | ||
|
|
96f5742511 | ||
|
|
9898024392 | ||
|
|
705844f312 | ||
|
|
1cbc049700 | ||
|
|
ee4096f141 | ||
|
|
b3e99de06c | ||
|
|
0d351179e4 | ||
|
|
8494534c8f | ||
|
|
ae728fe7ec | ||
|
|
b33a06c1e4 | ||
|
|
17a5f29306 |
@@ -117,6 +117,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
|
|
||||||
installTipsHelpFunc(rootCmd)
|
installTipsHelpFunc(rootCmd)
|
||||||
rootCmd.SilenceErrors = true
|
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)
|
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
||||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/transport"
|
"github.com/larksuite/cli/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// configInitResult holds the result of the interactive config init flow.
|
// configInitResult holds the result of the interactive config init flow.
|
||||||
@@ -179,7 +179,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
|||||||
// Step 1: Request app registration (begin)
|
// Step 1: Request app registration (begin)
|
||||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||||
// a bypass of proxy plugin mode.
|
// a bypass of proxy plugin mode.
|
||||||
httpClient := transport.NewHTTPClient(0)
|
httpClient := util.NewHTTPClient(0)
|
||||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/identitydiag"
|
"github.com/larksuite/cli/internal/identitydiag"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/transport"
|
|
||||||
"github.com/larksuite/cli/internal/update"
|
"github.com/larksuite/cli/internal/update"
|
||||||
|
"github.com/larksuite/cli/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DoctorOptions holds inputs for the doctor command.
|
// DoctorOptions holds inputs for the doctor command.
|
||||||
@@ -155,7 +155,7 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
|
|||||||
|
|
||||||
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
|
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
|
||||||
// the real egress path (and are blocked when proxy plugin fails closed).
|
// the real egress path (and are blocked when proxy plugin fails closed).
|
||||||
httpClient := transport.NewHTTPClient(0)
|
httpClient := util.NewHTTPClient(0)
|
||||||
mcpURL := ep.MCP + "/mcp"
|
mcpURL := ep.MCP + "/mcp"
|
||||||
|
|
||||||
type probeResult struct {
|
type probeResult struct {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
eventlib "github.com/larksuite/cli/internal/event"
|
eventlib "github.com/larksuite/cli/internal/event"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/suggest"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxSuggestions = 3
|
const maxSuggestions = 3
|
||||||
@@ -28,7 +29,7 @@ func suggestEventKeys(input string) []string {
|
|||||||
hits = append(hits, match{def.Key, 0})
|
hits = append(hits, match{def.Key, 0})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if d := levenshtein(input, def.Key); d <= threshold {
|
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
|
||||||
hits = append(hits, match{def.Key, d})
|
hits = append(hits, match{def.Key, d})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,34 +70,3 @@ func unknownEventKeyErr(key string) error {
|
|||||||
"Run 'lark-cli event list' to see available keys.",
|
"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,27 +10,6 @@ import (
|
|||||||
_ "github.com/larksuite/cli/events"
|
_ "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) {
|
func TestSuggestEventKeys(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
70
cmd/flag_suggest_test.go
Normal file
70
cmd/flag_suggest_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
90
cmd/root.go
90
cmd/root.go
@@ -24,8 +24,10 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
"github.com/larksuite/cli/internal/skillscheck"
|
"github.com/larksuite/cli/internal/skillscheck"
|
||||||
|
"github.com/larksuite/cli/internal/suggest"
|
||||||
"github.com/larksuite/cli/internal/update"
|
"github.com/larksuite/cli/internal/update"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
|
|
||||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||||
@@ -308,6 +310,12 @@ func asExitError(err error) *output.ExitError {
|
|||||||
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||||
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
||||||
cmd.RunE = unknownSubcommandRunE
|
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 {
|
if cmd.Annotations == nil {
|
||||||
cmd.Annotations = map[string]string{}
|
cmd.Annotations = map[string]string{}
|
||||||
}
|
}
|
||||||
@@ -331,10 +339,12 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
unknown := args[0]
|
unknown := args[0]
|
||||||
available := availableSubcommandNames(cmd)
|
available := availableSubcommandNames(cmd)
|
||||||
|
suggestions := suggest.Closest(unknown, available, 6)
|
||||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||||
if len(available) > 0 {
|
if len(suggestions) > 0 {
|
||||||
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
|
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
|
||||||
|
strings.Join(suggestions, ", "), cmd.CommandPath())
|
||||||
}
|
}
|
||||||
return &output.ExitError{
|
return &output.ExitError{
|
||||||
Code: output.ExitValidation,
|
Code: output.ExitValidation,
|
||||||
@@ -345,6 +355,7 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
|||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"unknown": unknown,
|
"unknown": unknown,
|
||||||
"command_path": cmd.CommandPath(),
|
"command_path": cmd.CommandPath(),
|
||||||
|
"suggestions": suggestions,
|
||||||
"available": available,
|
"available": available,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -367,6 +378,81 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
|
|||||||
return subs
|
return subs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||||
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
||||||
// flags that are normally hidden in single-app mode (currently --profile)
|
// flags that are normally hidden in single-app mode (currently --profile)
|
||||||
|
|||||||
@@ -113,11 +113,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
|
|||||||
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
|
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
|
||||||
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
|
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
|
||||||
}
|
}
|
||||||
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
|
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
|
||||||
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
|
// 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, "+secret") {
|
if !strings.Contains(exitErr.Detail.Hint, "--help") {
|
||||||
t.Error("hidden commands must not appear in the hint")
|
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||||
|
|||||||
@@ -4,12 +4,11 @@
|
|||||||
//go:build authsidecar
|
//go:build authsidecar
|
||||||
|
|
||||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an http:// or https://
|
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
|
||||||
// URL), all outgoing requests are rewritten to the sidecar address. The
|
// outgoing requests are rewritten to the sidecar address. The interceptor
|
||||||
// interceptor strips placeholder credentials, injects proxy headers, and
|
// strips placeholder credentials, injects proxy headers, and signs each
|
||||||
// signs each request with HMAC-SHA256. No custom DialContext is needed —
|
// request with HMAC-SHA256. No custom DialContext is needed — Go's
|
||||||
// Go's standard http.Transport connects to the sidecar via HTTP, or via
|
// standard http.Transport connects to the sidecar via plain HTTP.
|
||||||
// HTTPS (TLS) when the sidecar address is an https:// URL.
|
|
||||||
package sidecar
|
package sidecar
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -47,17 +46,15 @@ func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor
|
|||||||
}
|
}
|
||||||
key := os.Getenv(envvars.CliProxyKey)
|
key := os.Getenv(envvars.CliProxyKey)
|
||||||
return &Interceptor{
|
return &Interceptor{
|
||||||
key: []byte(key),
|
key: []byte(key),
|
||||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||||
sidecarScheme: sidecar.ProxyScheme(proxyAddr),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interceptor rewrites requests for the sidecar proxy.
|
// Interceptor rewrites requests for the sidecar proxy.
|
||||||
type Interceptor struct {
|
type Interceptor struct {
|
||||||
key []byte // HMAC signing key
|
key []byte // HMAC signing key
|
||||||
sidecarHost string // sidecar host[:port] for URL rewriting
|
sidecarHost string // sidecar host:port for URL rewriting
|
||||||
sidecarScheme string // "http" (same-host) or "https" (remote TLS sidecar)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||||
@@ -133,13 +130,8 @@ func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response,
|
|||||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||||
|
|
||||||
// 5. Rewrite URL to route through sidecar. Scheme follows the configured
|
// 5. Rewrite URL to route through sidecar
|
||||||
// proxy address: https for a remote (TLS) sidecar, http for a same-host one.
|
req.URL.Scheme = "http"
|
||||||
scheme := i.sidecarScheme
|
|
||||||
if scheme == "" {
|
|
||||||
scheme = "http"
|
|
||||||
}
|
|
||||||
req.URL.Scheme = scheme
|
|
||||||
req.URL.Host = i.sidecarHost
|
req.URL.Host = i.sidecarHost
|
||||||
|
|
||||||
return nil // no post-hook needed
|
return nil // no post-hook needed
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ package sidecar
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/envvars"
|
|
||||||
"github.com/larksuite/cli/sidecar"
|
"github.com/larksuite/cli/sidecar"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -99,54 +97,6 @@ func TestInterceptor_PreRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInterceptor_PreRoundTrip_HTTPS verifies that a remote (TLS) sidecar
|
|
||||||
// rewrites the request to https://<remote-host>, while still preserving the
|
|
||||||
// original target and signing the request.
|
|
||||||
func TestInterceptor_PreRoundTrip_HTTPS(t *testing.T) {
|
|
||||||
key := []byte("test-key-for-hmac-signing-32byte!")
|
|
||||||
interceptor := &Interceptor{key: key, sidecarHost: "sidecar.mycorp.com", sidecarScheme: "https"}
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/im/v1/chats", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
|
||||||
|
|
||||||
interceptor.PreRoundTrip(req)
|
|
||||||
|
|
||||||
if req.URL.Scheme != "https" {
|
|
||||||
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "https")
|
|
||||||
}
|
|
||||||
if req.URL.Host != "sidecar.mycorp.com" {
|
|
||||||
t.Errorf("host = %q, want %q", req.URL.Host, "sidecar.mycorp.com")
|
|
||||||
}
|
|
||||||
// Original target still preserved for the sidecar to forward upstream.
|
|
||||||
if target := req.Header.Get(sidecar.HeaderProxyTarget); target != "https://open.feishu.cn" {
|
|
||||||
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
|
||||||
}
|
|
||||||
// Request is still signed.
|
|
||||||
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
|
||||||
t.Error("signature header should be set")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestResolveInterceptor_HTTPSScheme pins the end-to-end env→scheme path: a
|
|
||||||
// (mixed-case) https proxy address must produce an interceptor that rewrites to
|
|
||||||
// https, never silently downgrading a remote sidecar to plaintext http.
|
|
||||||
func TestResolveInterceptor_HTTPSScheme(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAuthProxy, "HTTPS://sidecar.mycorp.com") // uppercase on purpose
|
|
||||||
t.Setenv(envvars.CliProxyKey, "key")
|
|
||||||
|
|
||||||
ic := (&Provider{}).ResolveInterceptor(context.Background())
|
|
||||||
si, ok := ic.(*Interceptor)
|
|
||||||
if !ok || si == nil {
|
|
||||||
t.Fatalf("expected *Interceptor, got %T", ic)
|
|
||||||
}
|
|
||||||
if si.sidecarScheme != "https" {
|
|
||||||
t.Errorf("sidecarScheme = %q, want %q (uppercase HTTPS must not downgrade)", si.sidecarScheme, "https")
|
|
||||||
}
|
|
||||||
if si.sidecarHost != "sidecar.mycorp.com" {
|
|
||||||
t.Errorf("sidecarHost = %q, want %q", si.sidecarHost, "sidecar.mycorp.com")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInterceptor_BotIdentity(t *testing.T) {
|
func TestInterceptor_BotIdentity(t *testing.T) {
|
||||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/errclass"
|
"github.com/larksuite/cli/internal/errclass"
|
||||||
"github.com/larksuite/cli/internal/transport"
|
"github.com/larksuite/cli/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
||||||
@@ -28,7 +28,7 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
|
|||||||
if t.Base != nil {
|
if t.Base != nil {
|
||||||
return t.Base
|
return t.Base
|
||||||
}
|
}
|
||||||
return transport.Fallback()
|
return util.FallbackTransport()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoundTrip implements http.RoundTripper.
|
// RoundTrip implements http.RoundTripper.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package cmdpolicy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/larksuite/cli/extension/platform"
|
"github.com/larksuite/cli/extension/platform"
|
||||||
|
"github.com/larksuite/cli/internal/suggest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// suggestRisk returns the closest valid Risk literal by edit distance
|
// suggestRisk returns the closest valid Risk literal by edit distance
|
||||||
@@ -20,9 +21,9 @@ func suggestRisk(bad string) string {
|
|||||||
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
|
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
|
||||||
}
|
}
|
||||||
best := string(candidates[0])
|
best := string(candidates[0])
|
||||||
bestDist := levenshtein(lowered, best)
|
bestDist := suggest.Levenshtein(lowered, best)
|
||||||
for _, c := range candidates[1:] {
|
for _, c := range candidates[1:] {
|
||||||
if d := levenshtein(lowered, string(c)); d < bestDist {
|
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
|
||||||
bestDist, best = d, string(c)
|
bestDist, best = d, string(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,47 +41,3 @@ func toLower(s string) string {
|
|||||||
}
|
}
|
||||||
return string(b)
|
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,23 +29,3 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
|
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
|
||||||
"github.com/larksuite/cli/internal/transport"
|
"github.com/larksuite/cli/internal/util"
|
||||||
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,15 +102,15 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
|||||||
|
|
||||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||||
return sync.OnceValues(func() (*http.Client, error) {
|
return sync.OnceValues(func() (*http.Client, error) {
|
||||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
util.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
|
||||||
|
|
||||||
var rt http.RoundTripper = transport.Shared()
|
var transport http.RoundTripper = util.SharedTransport()
|
||||||
rt = &RetryTransport{Base: rt}
|
transport = &RetryTransport{Base: transport}
|
||||||
rt = &SecurityHeaderTransport{Base: rt}
|
transport = &SecurityHeaderTransport{Base: transport}
|
||||||
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
|
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
|
||||||
rt = wrapWithExtension(rt)
|
transport = wrapWithExtension(transport)
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Transport: rt,
|
Transport: transport,
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
CheckRedirect: safeRedirectPolicy,
|
CheckRedirect: safeRedirectPolicy,
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
|||||||
lark.WithLogLevel(larkcore.LogLevelError),
|
lark.WithLogLevel(larkcore.LogLevelError),
|
||||||
lark.WithHeaders(BaseSecurityHeaders()),
|
lark.WithHeaders(BaseSecurityHeaders()),
|
||||||
}
|
}
|
||||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
util.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
|
||||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||||
Transport: buildSDKTransport(),
|
Transport: buildSDKTransport(),
|
||||||
CheckRedirect: safeRedirectPolicy,
|
CheckRedirect: safeRedirectPolicy,
|
||||||
@@ -141,7 +141,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildSDKTransport() http.RoundTripper {
|
func buildSDKTransport() http.RoundTripper {
|
||||||
var sdkTransport http.RoundTripper = transport.Shared()
|
var sdkTransport http.RoundTripper = util.SharedTransport()
|
||||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
exttransport "github.com/larksuite/cli/extension/transport"
|
exttransport "github.com/larksuite/cli/extension/transport"
|
||||||
"github.com/larksuite/cli/internal/transport"
|
"github.com/larksuite/cli/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RetryTransport is an http.RoundTripper that retries on 5xx responses
|
// RetryTransport is an http.RoundTripper that retries on 5xx responses
|
||||||
@@ -24,7 +24,7 @@ func (t *RetryTransport) base() http.RoundTripper {
|
|||||||
if t.Base != nil {
|
if t.Base != nil {
|
||||||
return t.Base
|
return t.Base
|
||||||
}
|
}
|
||||||
return transport.Fallback()
|
return util.FallbackTransport()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *RetryTransport) delay() time.Duration {
|
func (t *RetryTransport) delay() time.Duration {
|
||||||
@@ -69,7 +69,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
|||||||
if t.Base != nil {
|
if t.Base != nil {
|
||||||
return t.Base.RoundTrip(req)
|
return t.Base.RoundTrip(req)
|
||||||
}
|
}
|
||||||
return transport.Fallback().RoundTrip(req)
|
return util.FallbackTransport().RoundTrip(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildHeaderTransport is an http.RoundTripper that force-writes the
|
// BuildHeaderTransport is an http.RoundTripper that force-writes the
|
||||||
@@ -87,7 +87,7 @@ func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, err
|
|||||||
if t.Base != nil {
|
if t.Base != nil {
|
||||||
return t.Base.RoundTrip(req)
|
return t.Base.RoundTrip(req)
|
||||||
}
|
}
|
||||||
return transport.Fallback().RoundTrip(req)
|
return util.FallbackTransport().RoundTrip(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
|
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
|
||||||
@@ -100,7 +100,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
|
|||||||
if t.Base != nil {
|
if t.Base != nil {
|
||||||
return t.Base
|
return t.Base
|
||||||
}
|
}
|
||||||
return transport.Fallback()
|
return util.FallbackTransport()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoundTrip implements http.RoundTripper.
|
// RoundTrip implements http.RoundTripper.
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
|
|||||||
|
|
||||||
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
|
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
|
||||||
// the transport still sets X-Cli-Build and routes the request through
|
// the transport still sets X-Cli-Build and routes the request through
|
||||||
// transport.Fallback rather than panicking. This covers the fallback
|
// util.FallbackTransport rather than panicking. This covers the fallback
|
||||||
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
|
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
|
||||||
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
|
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
|
||||||
var receivedBuild string
|
var receivedBuild string
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const (
|
|||||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||||
|
|
||||||
// Sidecar proxy (auth proxy mode)
|
// Sidecar proxy (auth proxy mode)
|
||||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar address http(s)://host[:port]; plaintext http is same-host only, a remote sidecar must use https. e.g. "http://127.0.0.1:16384" or "https://sidecar.mycorp.com"
|
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
|
||||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||||
|
|
||||||
// Content safety scanning mode
|
// Content safety scanning mode
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// Package transport owns how the CLI assembles its outbound HTTP transport: the
|
// Package proxyplugin implements the ~/.lark-cli/proxy_config.json based security proxy plugin mode.
|
||||||
// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY
|
|
||||||
// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode.
|
|
||||||
//
|
//
|
||||||
// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback
|
// It supports:
|
||||||
// proxy, optionally trusting an extra root CA PEM bundle for TLS-inspection
|
// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy
|
||||||
// proxies, and fails closed on misconfiguration. Environment variables override
|
// - trusting an additional root CA PEM bundle for MITM/inspection proxies
|
||||||
// matching values from proxy_config.json.
|
//
|
||||||
package transport
|
// Environment variables override matching values from proxy_config.json.
|
||||||
|
package proxyplugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -223,6 +222,21 @@ func (c *Config) proxyURL() (*url.URL, error) {
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// redactProxyURL masks userinfo (username:password) in a proxy URL.
|
||||||
|
// Handles both scheme-prefixed ("http://user:pass@host") and bare formats.
|
||||||
|
func redactProxyURL(raw string) string {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err == nil && u.User != nil {
|
||||||
|
u.User = url.User("***")
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
// Fallback: handle "user:pass@proxy:8080"
|
||||||
|
if at := strings.LastIndex(raw, "@"); at > 0 {
|
||||||
|
return "***@" + raw[at+1:]
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyToTransport clones base and applies proxy plugin settings to the clone.
|
// ApplyToTransport clones base and applies proxy plugin settings to the clone.
|
||||||
// Caller owns the returned *http.Transport.
|
// Caller owns the returned *http.Transport.
|
||||||
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {
|
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package transport
|
package proxyplugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package transport
|
package proxyplugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package transport
|
package proxyplugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package transport
|
package proxyplugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -16,7 +16,7 @@ var proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport)
|
|||||||
|
|
||||||
// cachedBlockedTransport is a fail-closed transport cached on first use when
|
// cachedBlockedTransport is a fail-closed transport cached on first use when
|
||||||
// the proxy plugin config exists but is invalid. This avoids cloning
|
// the proxy plugin config exists but is invalid. This avoids cloning
|
||||||
// http.DefaultTransport on every pluginTransport call.
|
// http.DefaultTransport on every SharedTransport call.
|
||||||
var cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
|
var cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
|
||||||
|
|
||||||
func buildBlockedTransport() http.RoundTripper {
|
func buildBlockedTransport() http.RoundTripper {
|
||||||
@@ -28,7 +28,7 @@ func buildProxyPluginTransport() http.RoundTripper {
|
|||||||
if !ok {
|
if !ok {
|
||||||
// Cannot clone the stdlib transport. Fail closed with a concrete
|
// Cannot clone the stdlib transport. Fail closed with a concrete
|
||||||
// *http.Transport (not a bare RoundTripper) so downcasting callers such
|
// *http.Transport (not a bare RoundTripper) so downcasting callers such
|
||||||
// as Fallback cannot silently degrade this into a
|
// as util.FallbackTransport cannot silently degrade this into a
|
||||||
// direct-egress transport.
|
// direct-egress transport.
|
||||||
return failClosedTransport(fmt.Errorf("proxy plugin transport unavailable: http.DefaultTransport is %T, want *http.Transport", http.DefaultTransport))
|
return failClosedTransport(fmt.Errorf("proxy plugin transport unavailable: http.DefaultTransport is %T, want *http.Transport", http.DefaultTransport))
|
||||||
}
|
}
|
||||||
@@ -51,9 +51,9 @@ func buildProxyPluginTransport() http.RoundTripper {
|
|||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
// pluginTransport returns the proxy plugin transport when proxy plugin mode is
|
// SharedTransport returns the proxy plugin transport when proxy plugin mode is
|
||||||
// configured. The bool return is false when the plugin is not configured or not enabled.
|
// configured. The bool return is false when the plugin is not configured or not enabled.
|
||||||
func pluginTransport() (http.RoundTripper, bool) {
|
func SharedTransport() (http.RoundTripper, bool) {
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cachedBlockedTransport(), true
|
return cachedBlockedTransport(), true
|
||||||
@@ -68,7 +68,7 @@ func pluginTransport() (http.RoundTripper, bool) {
|
|||||||
// err. It clones http.DefaultTransport when possible (preserving dial/timeout
|
// err. It clones http.DefaultTransport when possible (preserving dial/timeout
|
||||||
// tuning); otherwise it builds a minimal transport. Returning a concrete
|
// tuning); otherwise it builds a minimal transport. Returning a concrete
|
||||||
// *http.Transport (rather than a bare RoundTripper) is required so downcasting
|
// *http.Transport (rather than a bare RoundTripper) is required so downcasting
|
||||||
// callers such as Fallback cannot silently degrade a fail-closed
|
// callers such as util.FallbackTransport cannot silently degrade a fail-closed
|
||||||
// signal into a direct-egress transport.
|
// signal into a direct-egress transport.
|
||||||
func failClosedTransport(err error) *http.Transport {
|
func failClosedTransport(err error) *http.Transport {
|
||||||
if def, ok := http.DefaultTransport.(*http.Transport); ok {
|
if def, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package transport
|
package proxyplugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -20,21 +20,21 @@ func resetProxyPluginState() {
|
|||||||
cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
|
cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPluginTransport_NotConfigured(t *testing.T) {
|
func TestSharedTransport_NotConfigured(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
unsetProxyPluginEnv(t)
|
unsetProxyPluginEnv(t)
|
||||||
resetProxyPluginState()
|
resetProxyPluginState()
|
||||||
|
|
||||||
tr, ok := pluginTransport()
|
tr, ok := SharedTransport()
|
||||||
if ok {
|
if ok {
|
||||||
t.Fatalf("pluginTransport() ok = true, want false")
|
t.Fatalf("SharedTransport() ok = true, want false")
|
||||||
}
|
}
|
||||||
if tr != nil {
|
if tr != nil {
|
||||||
t.Fatalf("pluginTransport() transport = %T, want nil", tr)
|
t.Fatalf("SharedTransport() transport = %T, want nil", tr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPluginTransport_EnabledReturnsFixedProxy(t *testing.T) {
|
func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
unsetProxyPluginEnv(t)
|
unsetProxyPluginEnv(t)
|
||||||
resetProxyPluginState()
|
resetProxyPluginState()
|
||||||
@@ -46,13 +46,13 @@ func TestPluginTransport_EnabledReturnsFixedProxy(t *testing.T) {
|
|||||||
"LARKSUITE_CLI_CA_PATH": ""
|
"LARKSUITE_CLI_CA_PATH": ""
|
||||||
}`), 0600)
|
}`), 0600)
|
||||||
|
|
||||||
rt, ok := pluginTransport()
|
rt, ok := SharedTransport()
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("pluginTransport() ok = false, want true")
|
t.Fatal("SharedTransport() ok = false, want true")
|
||||||
}
|
}
|
||||||
tr, ok := rt.(*http.Transport)
|
tr, ok := rt.(*http.Transport)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("pluginTransport() = %T, want *http.Transport", rt)
|
t.Fatalf("SharedTransport() = %T, want *http.Transport", rt)
|
||||||
}
|
}
|
||||||
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,7 +63,7 @@ func TestPluginTransport_EnabledReturnsFixedProxy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPluginTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) {
|
func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
unsetProxyPluginEnv(t)
|
unsetProxyPluginEnv(t)
|
||||||
resetProxyPluginState()
|
resetProxyPluginState()
|
||||||
@@ -72,12 +72,12 @@ func TestPluginTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test
|
|||||||
|
|
||||||
writeFile(t, Path(), []byte(`{`), 0600)
|
writeFile(t, Path(), []byte(`{`), 0600)
|
||||||
|
|
||||||
rt, ok := pluginTransport()
|
rt, ok := SharedTransport()
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("pluginTransport() ok = false, want true")
|
t.Fatal("SharedTransport() ok = false, want true")
|
||||||
}
|
}
|
||||||
if rt == http.DefaultTransport {
|
if rt == http.DefaultTransport {
|
||||||
t.Fatalf("pluginTransport() returned http.DefaultTransport, want fail-closed transport")
|
t.Fatalf("SharedTransport() returned http.DefaultTransport, want fail-closed transport")
|
||||||
}
|
}
|
||||||
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -88,23 +88,23 @@ func TestPluginTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPluginTransport_InvalidConfigReturnsCachedInstance(t *testing.T) {
|
func TestSharedTransport_InvalidConfigReturnsCachedInstance(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
unsetProxyPluginEnv(t)
|
unsetProxyPluginEnv(t)
|
||||||
resetProxyPluginState()
|
resetProxyPluginState()
|
||||||
|
|
||||||
writeFile(t, Path(), []byte(`{`), 0600)
|
writeFile(t, Path(), []byte(`{`), 0600)
|
||||||
|
|
||||||
a, ok := pluginTransport()
|
a, ok := SharedTransport()
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("pluginTransport() ok = false, want true")
|
t.Fatal("SharedTransport() ok = false, want true")
|
||||||
}
|
}
|
||||||
b, ok := pluginTransport()
|
b, ok := SharedTransport()
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("pluginTransport() ok = false, want true")
|
t.Fatal("SharedTransport() ok = false, want true")
|
||||||
}
|
}
|
||||||
if a != b {
|
if a != b {
|
||||||
t.Fatalf("pluginTransport() returned different instances on repeated calls; blocked transport must be cached")
|
t.Fatalf("SharedTransport() returned different instances on repeated calls; blocked transport must be cached")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,13 +148,13 @@ func TestBuildProxyPluginTransport_NonTransportDefaultFailsClosed(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPluginTransport_InvalidConfigBlockerIsConcreteTransport guards the
|
// TestSharedTransport_InvalidConfigBlockerIsConcreteTransport guards the
|
||||||
// fail-closed invariant that Fallback relies on: even when
|
// fail-closed invariant that util.FallbackTransport relies on: even when
|
||||||
// http.DefaultTransport is not an *http.Transport, an invalid proxy config must
|
// http.DefaultTransport is not an *http.Transport, an invalid proxy config must
|
||||||
// produce a blocked transport that is itself a concrete *http.Transport. If it
|
// produce a blocked transport that is itself a concrete *http.Transport. If it
|
||||||
// were a bare RoundTripper, Fallback would downcast-fail and
|
// were a bare RoundTripper, util.FallbackTransport would downcast-fail and
|
||||||
// silently degrade it into a direct-egress transport.
|
// silently degrade it into a direct-egress transport.
|
||||||
func TestPluginTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
|
func TestSharedTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
unsetProxyPluginEnv(t)
|
unsetProxyPluginEnv(t)
|
||||||
resetProxyPluginState()
|
resetProxyPluginState()
|
||||||
@@ -163,12 +163,12 @@ func TestPluginTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
|
|||||||
|
|
||||||
writeFile(t, Path(), []byte(`{`), 0600)
|
writeFile(t, Path(), []byte(`{`), 0600)
|
||||||
|
|
||||||
rt, ok := pluginTransport()
|
rt, ok := SharedTransport()
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("pluginTransport() ok = false, want true")
|
t.Fatal("SharedTransport() ok = false, want true")
|
||||||
}
|
}
|
||||||
if _, isTransport := rt.(*http.Transport); !isTransport {
|
if _, isTransport := rt.(*http.Transport); !isTransport {
|
||||||
t.Fatalf("pluginTransport() blocked transport = %T, want *http.Transport so Fallback cannot degrade it to direct egress", rt)
|
t.Fatalf("SharedTransport() blocked transport = %T, want *http.Transport so FallbackTransport cannot degrade it to direct egress", rt)
|
||||||
}
|
}
|
||||||
// Must remain fail-closed.
|
// Must remain fail-closed.
|
||||||
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/transport"
|
"github.com/larksuite/cli/internal/util"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
)
|
)
|
||||||
@@ -181,7 +181,7 @@ func saveCachedMerged(data []byte, meta CacheMeta) error {
|
|||||||
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
|
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
|
||||||
// Route through the shared proxy-plugin-aware transport so remote API
|
// Route through the shared proxy-plugin-aware transport so remote API
|
||||||
// definition fetches honor proxy plugin mode instead of bypassing it.
|
// definition fetches honor proxy plugin mode instead of bypassing it.
|
||||||
client := transport.NewHTTPClient(fetchTimeout)
|
client := util.NewHTTPClient(fetchTimeout)
|
||||||
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
|
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|||||||
104
internal/suggest/suggest.go
Normal file
104
internal/suggest/suggest.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
74
internal/suggest/suggest_test.go
Normal file
74
internal/suggest/suggest_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// 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,83 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package transport
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Shared returns the base http.RoundTripper for all CLI HTTP clients.
|
|
||||||
//
|
|
||||||
// Precedence (highest first):
|
|
||||||
// 1. proxy-plugin mode — force traffic through a fixed loopback proxy;
|
|
||||||
// FAIL-CLOSED when the plugin config exists but is invalid.
|
|
||||||
// 2. LARK_CLI_NO_PROXY — direct egress, proxy disabled.
|
|
||||||
// 3. http.DefaultTransport — the stdlib process-wide singleton (honors
|
|
||||||
// HTTP(S)_PROXY), so every client shares one connection pool / TLS cache.
|
|
||||||
//
|
|
||||||
// The returned RoundTripper MUST NOT be mutated. Callers that need a customized
|
|
||||||
// transport should assert to *http.Transport and Clone() it. A shared base is
|
|
||||||
// required so persistConn read/write goroutines are reused; cloning per call
|
|
||||||
// leaks them until IdleConnTimeout (~90s) fires.
|
|
||||||
func Shared() http.RoundTripper {
|
|
||||||
// Proxy-plugin mode overrides everything, INCLUDING LARK_CLI_NO_PROXY. When
|
|
||||||
// the plugin config exists but is invalid, pluginTransport returns a
|
|
||||||
// fail-closed transport with ok=true and we return it here — we MUST NOT
|
|
||||||
// fall through to the NO_PROXY / DefaultTransport direct-egress paths below.
|
|
||||||
if t, ok := pluginTransport(); ok {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
if os.Getenv(EnvNoProxy) != "" {
|
|
||||||
return noProxyTransport()
|
|
||||||
}
|
|
||||||
return http.DefaultTransport
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback returns a shared *http.Transport. It is a thin wrapper over Shared
|
|
||||||
// retained so modules already on the leak-free singleton path (internal/auth,
|
|
||||||
// internal/cmdutil transport decorators) do not have to migrate. New code
|
|
||||||
// should prefer Shared and treat the base as an http.RoundTripper.
|
|
||||||
//
|
|
||||||
// Fail-closed invariant: pluginTransport always expresses its blocked transport
|
|
||||||
// as a concrete *http.Transport (see failClosedTransport), so the assertion
|
|
||||||
// below preserves the block. The noProxyTransport() fallback is therefore only
|
|
||||||
// reached when no proxy plugin is configured and some external code replaced
|
|
||||||
// http.DefaultTransport with a non-*http.Transport — a case with no fail-closed
|
|
||||||
// intent, where a proxy-disabled transport is acceptable.
|
|
||||||
func Fallback() *http.Transport {
|
|
||||||
if t, ok := Shared().(*http.Transport); ok {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
return noProxyTransport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHTTPClient returns an *http.Client whose Transport is the shared,
|
|
||||||
// proxy-plugin-aware base (see Shared). Prefer this over a bare &http.Client{}
|
|
||||||
// for outbound requests: a bare client falls back to http.DefaultTransport and
|
|
||||||
// therefore silently bypasses proxy plugin mode (fixed proxy + trusted CA, or
|
|
||||||
// fail-closed), creating an audit blind spot.
|
|
||||||
//
|
|
||||||
// A zero timeout means no client-level timeout (callers relying on context
|
|
||||||
// deadlines pass 0).
|
|
||||||
func NewHTTPClient(timeout time.Duration) *http.Client {
|
|
||||||
return &http.Client{
|
|
||||||
Transport: Shared(),
|
|
||||||
Timeout: timeout,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, lazily
|
|
||||||
// built the first time LARK_CLI_NO_PROXY is observed set.
|
|
||||||
var noProxyTransport = sync.OnceValue(func() *http.Transport {
|
|
||||||
def, ok := http.DefaultTransport.(*http.Transport)
|
|
||||||
if !ok {
|
|
||||||
return &http.Transport{}
|
|
||||||
}
|
|
||||||
t := def.Clone()
|
|
||||||
t.Proxy = nil
|
|
||||||
return t
|
|
||||||
})
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package transport
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestShared_DefaultReturnsStdlibSingleton verifies the default shared transport.
|
|
||||||
func TestShared_DefaultReturnsStdlibSingleton(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
unsetProxyPluginEnv(t)
|
|
||||||
resetProxyPluginState()
|
|
||||||
t.Setenv(EnvNoProxy, "")
|
|
||||||
if Shared() != http.DefaultTransport {
|
|
||||||
t.Error("Shared should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestShared_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport.
|
|
||||||
func TestShared_NoProxyReturnsClone(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
unsetProxyPluginEnv(t)
|
|
||||||
resetProxyPluginState()
|
|
||||||
t.Setenv(EnvNoProxy, "1")
|
|
||||||
tr := Shared()
|
|
||||||
if tr == http.DefaultTransport {
|
|
||||||
t.Fatal("Shared should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set")
|
|
||||||
}
|
|
||||||
ht, ok := tr.(*http.Transport)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected *http.Transport, got %T", tr)
|
|
||||||
}
|
|
||||||
if ht.Proxy != nil {
|
|
||||||
t.Error("no-proxy transport should have Proxy == nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestShared_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport.
|
|
||||||
func TestShared_NoProxyIsCachedSingleton(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
unsetProxyPluginEnv(t)
|
|
||||||
resetProxyPluginState()
|
|
||||||
t.Setenv(EnvNoProxy, "1")
|
|
||||||
if Shared() != Shared() {
|
|
||||||
t.Error("repeated Shared calls with LARK_CLI_NO_PROXY set must return the same instance")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestShared_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib
|
|
||||||
// transport after unsetting LARK_CLI_NO_PROXY.
|
|
||||||
func TestShared_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
unsetProxyPluginEnv(t)
|
|
||||||
resetProxyPluginState()
|
|
||||||
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
|
|
||||||
// the no-proxy singleton), then unsets it. Subsequent calls must return
|
|
||||||
// http.DefaultTransport, NOT the cached no-proxy clone.
|
|
||||||
t.Setenv(EnvNoProxy, "1")
|
|
||||||
if Shared() == http.DefaultTransport {
|
|
||||||
t.Fatal("precondition: first call with env set should not return DefaultTransport")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv(EnvNoProxy, "")
|
|
||||||
if after := Shared(); after != http.DefaultTransport {
|
|
||||||
t.Errorf("after unsetting LARK_CLI_NO_PROXY, Shared must return http.DefaultTransport, got %T", after)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestShared_NoProxyOverridesSystemProxy verifies that LARK_CLI_NO_PROXY disables system proxies.
|
|
||||||
func TestShared_NoProxyOverridesSystemProxy(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
unsetProxyPluginEnv(t)
|
|
||||||
resetProxyPluginState()
|
|
||||||
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
|
|
||||||
t.Setenv(EnvNoProxy, "1")
|
|
||||||
|
|
||||||
ht, ok := Shared().(*http.Transport)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected *http.Transport, got %T", Shared())
|
|
||||||
}
|
|
||||||
if ht.Proxy != nil {
|
|
||||||
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware
|
|
||||||
// transport (instead of a bare client that bypasses proxy plugin mode).
|
|
||||||
func TestNewHTTPClient(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
unsetProxyPluginEnv(t)
|
|
||||||
resetProxyPluginState()
|
|
||||||
t.Setenv(EnvNoProxy, "")
|
|
||||||
|
|
||||||
c := NewHTTPClient(7 * time.Second)
|
|
||||||
if c.Transport == nil {
|
|
||||||
t.Fatal("NewHTTPClient transport is nil; want shared transport")
|
|
||||||
}
|
|
||||||
if c.Transport != Shared() {
|
|
||||||
t.Errorf("NewHTTPClient transport = %v, want Shared()", c.Transport)
|
|
||||||
}
|
|
||||||
if c.Timeout != 7*time.Second {
|
|
||||||
t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestShared_PluginOverridesNoProxy locks the contract that proxy-plugin mode wins
|
|
||||||
// over LARK_CLI_NO_PROXY: even with NO_PROXY set, an enabled plugin forces the proxy.
|
|
||||||
func TestShared_PluginOverridesNoProxy(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
unsetProxyPluginEnv(t)
|
|
||||||
t.Setenv(EnvNoProxy, "1") // NO_PROXY set, but the plugin must win
|
|
||||||
resetProxyPluginState()
|
|
||||||
|
|
||||||
writeFile(t, Path(), []byte(`{
|
|
||||||
"LARKSUITE_CLI_PROXY_ENABLE": true,
|
|
||||||
"LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128"
|
|
||||||
}`), 0600)
|
|
||||||
|
|
||||||
tr, ok := Shared().(*http.Transport)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Shared() = %T, want proxy *http.Transport, not the NO_PROXY clone", tr)
|
|
||||||
}
|
|
||||||
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
|
||||||
if err != nil || u == nil || u.String() != "http://127.0.0.1:3128" {
|
|
||||||
t.Fatalf("Proxy() = %v, %v; plugin must override NO_PROXY with the fixed proxy", u, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestShared_MalformedConfigFailsClosedEvenWithNoProxy locks the most dangerous
|
|
||||||
// invariant of the fold: a malformed proxy_config.json must FAIL CLOSED, never
|
|
||||||
// fall through to direct egress — not even to the LARK_CLI_NO_PROXY clone.
|
|
||||||
func TestShared_MalformedConfigFailsClosedEvenWithNoProxy(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
unsetProxyPluginEnv(t)
|
|
||||||
t.Setenv(EnvNoProxy, "1")
|
|
||||||
resetProxyPluginState()
|
|
||||||
|
|
||||||
writeFile(t, Path(), []byte(`{`), 0600) // malformed
|
|
||||||
|
|
||||||
rt := Shared()
|
|
||||||
if rt == http.DefaultTransport {
|
|
||||||
t.Fatal("malformed config returned http.DefaultTransport — fail OPEN")
|
|
||||||
}
|
|
||||||
if rt == noProxyTransport() {
|
|
||||||
t.Fatal("malformed config fell through to the NO_PROXY direct-egress clone — fail OPEN")
|
|
||||||
}
|
|
||||||
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("RoundTrip() err = nil (resp=%v); malformed config must fail closed", resp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package transport
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/envvars"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Proxy environment constants control shared transport proxy behavior.
|
|
||||||
const (
|
|
||||||
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
|
|
||||||
EnvNoProxy = "LARK_CLI_NO_PROXY"
|
|
||||||
)
|
|
||||||
|
|
||||||
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
|
|
||||||
var proxyEnvKeys = []string{
|
|
||||||
"HTTPS_PROXY", "https_proxy",
|
|
||||||
"HTTP_PROXY", "http_proxy",
|
|
||||||
"ALL_PROXY", "all_proxy",
|
|
||||||
}
|
|
||||||
|
|
||||||
// DetectProxyEnv returns the first proxy-related environment variable that is set,
|
|
||||||
// or empty strings if none are configured.
|
|
||||||
func DetectProxyEnv() (key, value string) {
|
|
||||||
for _, k := range proxyEnvKeys {
|
|
||||||
if v := os.Getenv(k); v != "" {
|
|
||||||
return k, v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// proxyWarningOnce ensures proxy environment warnings are emitted at most once.
|
|
||||||
var proxyWarningOnce sync.Once
|
|
||||||
|
|
||||||
// proxyPluginStatus reports the configured proxy plugin address, the extra
|
|
||||||
// trusted CA path (if any), and whether proxy plugin mode is enabled. It is
|
|
||||||
// indirected through a package variable so tests can simulate plugin-enabled
|
|
||||||
// mode without the process-global Load() sync.Once cache.
|
|
||||||
var proxyPluginStatus = func() (addr, caPath string, enabled bool) {
|
|
||||||
cfg, err := Load()
|
|
||||||
if err != nil || !cfg.Enabled() {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
return cfg.Proxy, cfg.CAPath, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// redactProxyURL masks userinfo (username:password) in a proxy URL.
|
|
||||||
// Handles both scheme-prefixed ("http://user:pass@host") and bare ("user:pass@host") formats.
|
|
||||||
func redactProxyURL(raw string) string {
|
|
||||||
// Try standard url.Parse first (works when scheme is present)
|
|
||||||
u, err := url.Parse(raw)
|
|
||||||
if err == nil && u.User != nil {
|
|
||||||
return u.Scheme + "://***@" + u.Host + u.RequestURI()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: handle bare URLs without scheme (e.g. "user:pass@proxy:8080")
|
|
||||||
if at := strings.LastIndex(raw, "@"); at > 0 {
|
|
||||||
return "***@" + raw[at+1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
|
|
||||||
// WarnIfProxied prints a one-time warning to w when a proxy environment variable
|
|
||||||
// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials
|
|
||||||
// are redacted. Safe to call multiple times; only the first call prints.
|
|
||||||
func WarnIfProxied(w io.Writer) {
|
|
||||||
proxyWarningOnce.Do(func() {
|
|
||||||
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
|
|
||||||
// Shared), so its warning and disable instructions take precedence.
|
|
||||||
// Emitting the env-proxy warning here would be misleading: it tells the
|
|
||||||
// user to set LARK_CLI_NO_PROXY=1, which does NOT disable the plugin proxy.
|
|
||||||
if addr, caPath, enabled := proxyPluginStatus(); enabled {
|
|
||||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin enabled: all requests (including credentials) are forced through %s. To disable, set %s=false or remove %s.\n",
|
|
||||||
redactProxyURL(addr), envvars.CliProxyEnable, Path())
|
|
||||||
if strings.TrimSpace(caPath) != "" {
|
|
||||||
// A custom CA means upstream TLS can be intercepted/inspected by
|
|
||||||
// the proxy (MITM). Surface it so the operator is aware traffic
|
|
||||||
// (including Bearer tokens) is decryptable on this host.
|
|
||||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin trusts a custom CA (%s); TLS to upstreams can be intercepted/inspected by this proxy.\n",
|
|
||||||
caPath)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if os.Getenv(EnvNoProxy) != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key, val := DetectProxyEnv()
|
|
||||||
if key == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
|
|
||||||
key, redactProxyURL(val), EnvNoProxy)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/transport"
|
"github.com/larksuite/cli/internal/util"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
)
|
)
|
||||||
@@ -64,7 +64,7 @@ func httpClient() *http.Client {
|
|||||||
}
|
}
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Timeout: fetchTimeout,
|
Timeout: fetchTimeout,
|
||||||
Transport: transport.Shared(),
|
Transport: util.SharedTransport(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
191
internal/util/proxy.go
Normal file
191
internal/util/proxy.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
|
"github.com/larksuite/cli/internal/proxyplugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Proxy environment constants control shared transport proxy behavior.
|
||||||
|
const (
|
||||||
|
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
|
||||||
|
EnvNoProxy = "LARK_CLI_NO_PROXY"
|
||||||
|
)
|
||||||
|
|
||||||
|
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
|
||||||
|
var proxyEnvKeys = []string{
|
||||||
|
"HTTPS_PROXY", "https_proxy",
|
||||||
|
"HTTP_PROXY", "http_proxy",
|
||||||
|
"ALL_PROXY", "all_proxy",
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectProxyEnv returns the first proxy-related environment variable that is set,
|
||||||
|
// or empty strings if none are configured.
|
||||||
|
func DetectProxyEnv() (key, value string) {
|
||||||
|
for _, k := range proxyEnvKeys {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return k, v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyWarningOnce ensures proxy environment warnings are emitted at most once.
|
||||||
|
var proxyWarningOnce sync.Once
|
||||||
|
|
||||||
|
// proxyPluginStatus reports the configured proxy plugin address, the extra
|
||||||
|
// trusted CA path (if any), and whether proxy plugin mode is enabled. It is
|
||||||
|
// indirected through a package variable so tests can simulate plugin-enabled
|
||||||
|
// mode without the process-global proxyplugin.Load() sync.Once cache.
|
||||||
|
var proxyPluginStatus = func() (addr, caPath string, enabled bool) {
|
||||||
|
cfg, err := proxyplugin.Load()
|
||||||
|
if err != nil || !cfg.Enabled() {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return cfg.Proxy, cfg.CAPath, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactProxyURL masks userinfo (username:password) in a proxy URL.
|
||||||
|
// Handles both scheme-prefixed ("http://user:pass@host") and bare ("user:pass@host") formats.
|
||||||
|
func redactProxyURL(raw string) string {
|
||||||
|
// Try standard url.Parse first (works when scheme is present)
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err == nil && u.User != nil {
|
||||||
|
return u.Scheme + "://***@" + u.Host + u.RequestURI()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: handle bare URLs without scheme (e.g. "user:pass@proxy:8080")
|
||||||
|
if at := strings.LastIndex(raw, "@"); at > 0 {
|
||||||
|
return "***@" + raw[at+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// WarnIfProxied prints a one-time warning to w when a proxy environment variable
|
||||||
|
// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials
|
||||||
|
// are redacted. Safe to call multiple times; only the first call prints.
|
||||||
|
//
|
||||||
|
// The warning is suppressed entirely when interactive is false — i.e. stdin is
|
||||||
|
// not a TTY, which is the case for agent / CI / piped invocations. Those callers
|
||||||
|
// frequently parse the CLI's stdout as JSON and merge streams with `2>&1`; a
|
||||||
|
// stray stderr warning then corrupts the parsed payload. Suppressing in the
|
||||||
|
// non-interactive case keeps machine-consumed output clean, while human
|
||||||
|
// interactive sessions still get the security notice. Passing interactive=false
|
||||||
|
// does not consume the once guard, so a later interactive call can still warn.
|
||||||
|
func WarnIfProxied(w io.Writer, interactive bool) {
|
||||||
|
if !interactive {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proxyWarningOnce.Do(func() {
|
||||||
|
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
|
||||||
|
// SharedTransport), so its warning and disable instructions take
|
||||||
|
// precedence. Emitting the env-proxy warning here would be misleading:
|
||||||
|
// it tells the user to set LARK_CLI_NO_PROXY=1, which does NOT disable
|
||||||
|
// the plugin proxy.
|
||||||
|
if addr, caPath, enabled := proxyPluginStatus(); enabled {
|
||||||
|
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin enabled: all requests (including credentials) are forced through %s. To disable, set %s=false or remove %s.\n",
|
||||||
|
redactProxyURL(addr), envvars.CliProxyEnable, proxyplugin.Path())
|
||||||
|
if strings.TrimSpace(caPath) != "" {
|
||||||
|
// A custom CA means upstream TLS can be intercepted/inspected by
|
||||||
|
// the proxy (MITM). Surface it so the operator is aware traffic
|
||||||
|
// (including Bearer tokens) is decryptable on this host.
|
||||||
|
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin trusts a custom CA (%s); TLS to upstreams can be intercepted/inspected by this proxy.\n",
|
||||||
|
caPath)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if os.Getenv(EnvNoProxy) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, val := DetectProxyEnv()
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
|
||||||
|
key, redactProxyURL(val), EnvNoProxy)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport,
|
||||||
|
// lazily built the first time LARK_CLI_NO_PROXY is observed set.
|
||||||
|
var noProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||||
|
def, ok := http.DefaultTransport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
return &http.Transport{}
|
||||||
|
}
|
||||||
|
t := def.Clone()
|
||||||
|
t.Proxy = nil
|
||||||
|
return t
|
||||||
|
})
|
||||||
|
|
||||||
|
// SharedTransport returns the base http.RoundTripper for CLI HTTP clients.
|
||||||
|
//
|
||||||
|
// By default it returns http.DefaultTransport — the stdlib-provided
|
||||||
|
// process-wide singleton — so every HTTP client in the process shares one
|
||||||
|
// TCP connection pool, TLS session cache, and HTTP/2 state. When
|
||||||
|
// LARK_CLI_NO_PROXY is set it returns a separate proxy-disabled singleton
|
||||||
|
// clone; LARK_CLI_NO_PROXY is checked on every call, but the clone is built
|
||||||
|
// at most once.
|
||||||
|
//
|
||||||
|
// The returned RoundTripper MUST NOT be mutated. Callers that need a
|
||||||
|
// customized transport should assert to *http.Transport and Clone() it.
|
||||||
|
// Using a shared base is required so persistConn readLoop/writeLoop
|
||||||
|
// goroutines are reused; cloning per call leaks them until IdleConnTimeout
|
||||||
|
// (~90s) fires.
|
||||||
|
func SharedTransport() http.RoundTripper {
|
||||||
|
// proxy plugin mode overrides all other proxy behavior (env proxies and
|
||||||
|
// LARK_CLI_NO_PROXY), per operator intent.
|
||||||
|
if t, ok := proxyplugin.SharedTransport(); ok {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
if os.Getenv(EnvNoProxy) != "" {
|
||||||
|
return noProxyTransport()
|
||||||
|
}
|
||||||
|
return http.DefaultTransport
|
||||||
|
}
|
||||||
|
|
||||||
|
// FallbackTransport returns a shared *http.Transport singleton. It is a
|
||||||
|
// thin wrapper over SharedTransport retained so modules that were already
|
||||||
|
// on the leak-free singleton path (internal/auth, internal/cmdutil
|
||||||
|
// transport decorators) do not have to migrate. New code should prefer
|
||||||
|
// SharedTransport and treat the base as an http.RoundTripper.
|
||||||
|
//
|
||||||
|
// Fail-closed invariant: proxyplugin always expresses its blocked/fail-closed
|
||||||
|
// transport as a concrete *http.Transport (see proxyplugin.failClosedTransport),
|
||||||
|
// so the assertion below preserves the block. The noProxyTransport() fallback is
|
||||||
|
// therefore only reached when no proxy plugin is configured and some external
|
||||||
|
// code replaced http.DefaultTransport with a non-*http.Transport — a case with
|
||||||
|
// no fail-closed intent, where a proxy-disabled transport is acceptable.
|
||||||
|
func FallbackTransport() *http.Transport {
|
||||||
|
if t, ok := SharedTransport().(*http.Transport); ok {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return noProxyTransport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPClient returns an *http.Client whose Transport is the shared,
|
||||||
|
// proxy-plugin-aware base (see SharedTransport). Prefer this over a bare
|
||||||
|
// &http.Client{} for outbound requests: a bare client falls back to
|
||||||
|
// http.DefaultTransport and therefore silently bypasses proxy plugin mode
|
||||||
|
// (fixed proxy + trusted CA, or fail-closed), creating an audit blind spot.
|
||||||
|
//
|
||||||
|
// A zero timeout means no client-level timeout (callers relying on
|
||||||
|
// context deadlines pass 0).
|
||||||
|
func NewHTTPClient(timeout time.Duration) *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Transport: SharedTransport(),
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,44 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package transport
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/envvars"
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// unsetEnv clears key for the duration of the test and restores its original value.
|
||||||
|
func unsetEnv(t *testing.T, key string) {
|
||||||
|
t.Helper()
|
||||||
|
old, had := os.LookupEnv(key)
|
||||||
|
_ = os.Unsetenv(key)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if had {
|
||||||
|
_ = os.Setenv(key, old)
|
||||||
|
} else {
|
||||||
|
_ = os.Unsetenv(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// unsetProxyPluginEnv clears proxy-related environment variables for deterministic tests.
|
||||||
|
func unsetProxyPluginEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
// Ensure developer machine env doesn't accidentally enable proxy plugin mode
|
||||||
|
// and change expectations for SharedTransport().
|
||||||
|
unsetEnv(t, envvars.CliProxyEnable)
|
||||||
|
unsetEnv(t, envvars.CliProxyAddress)
|
||||||
|
unsetEnv(t, envvars.CliCAPath)
|
||||||
|
}
|
||||||
|
|
||||||
// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
|
// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
|
||||||
func TestDetectProxyEnv(t *testing.T) {
|
func TestDetectProxyEnv(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
@@ -34,17 +61,94 @@ func TestDetectProxyEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSharedTransport_DefaultReturnsStdlibSingleton verifies the default shared transport.
|
||||||
|
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
unsetProxyPluginEnv(t)
|
||||||
|
t.Setenv(EnvNoProxy, "")
|
||||||
|
tr := SharedTransport()
|
||||||
|
if tr != http.DefaultTransport {
|
||||||
|
t.Error("SharedTransport should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSharedTransport_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport.
|
||||||
|
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
unsetProxyPluginEnv(t)
|
||||||
|
t.Setenv(EnvNoProxy, "1")
|
||||||
|
tr := SharedTransport()
|
||||||
|
if tr == http.DefaultTransport {
|
||||||
|
t.Fatal("SharedTransport should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set")
|
||||||
|
}
|
||||||
|
ht, ok := tr.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *http.Transport, got %T", tr)
|
||||||
|
}
|
||||||
|
if ht.Proxy != nil {
|
||||||
|
t.Error("no-proxy transport should have Proxy == nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSharedTransport_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport.
|
||||||
|
func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
unsetProxyPluginEnv(t)
|
||||||
|
t.Setenv(EnvNoProxy, "1")
|
||||||
|
a := SharedTransport()
|
||||||
|
b := SharedTransport()
|
||||||
|
if a != b {
|
||||||
|
t.Error("repeated SharedTransport calls with LARK_CLI_NO_PROXY set must return the same instance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib transport after unsetting EnvNoProxy.
|
||||||
|
func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
unsetProxyPluginEnv(t)
|
||||||
|
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
|
||||||
|
// the no-proxy singleton), then unsets it. Subsequent calls must return
|
||||||
|
// http.DefaultTransport, NOT the cached no-proxy clone.
|
||||||
|
t.Setenv(EnvNoProxy, "1")
|
||||||
|
noProxy := SharedTransport()
|
||||||
|
if noProxy == http.DefaultTransport {
|
||||||
|
t.Fatal("precondition: first call with env set should not return DefaultTransport")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv(EnvNoProxy, "")
|
||||||
|
after := SharedTransport()
|
||||||
|
if after != http.DefaultTransport {
|
||||||
|
t.Errorf("after unsetting LARK_CLI_NO_PROXY, SharedTransport must return http.DefaultTransport, got %T (%p)", after, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSharedTransport_NoProxyOverridesSystemProxy verifies that EnvNoProxy disables system proxies.
|
||||||
|
func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
unsetProxyPluginEnv(t)
|
||||||
|
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
|
||||||
|
t.Setenv(EnvNoProxy, "1")
|
||||||
|
|
||||||
|
ht, ok := SharedTransport().(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *http.Transport, got %T", SharedTransport())
|
||||||
|
}
|
||||||
|
if ht.Proxy != nil {
|
||||||
|
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning.
|
// TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning.
|
||||||
func TestWarnIfProxied_WithProxy(t *testing.T) {
|
func TestWarnIfProxied_WithProxy(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
unsetProxyPluginEnv(t)
|
unsetProxyPluginEnv(t)
|
||||||
resetProxyPluginState()
|
// Reset the once guard for this test
|
||||||
proxyWarningOnce = sync.Once{}
|
proxyWarningOnce = sync.Once{}
|
||||||
|
|
||||||
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
|
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
WarnIfProxied(&buf)
|
WarnIfProxied(&buf, true)
|
||||||
|
|
||||||
out := buf.String()
|
out := buf.String()
|
||||||
if out == "" {
|
if out == "" {
|
||||||
@@ -62,7 +166,6 @@ func TestWarnIfProxied_WithProxy(t *testing.T) {
|
|||||||
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
unsetProxyPluginEnv(t)
|
unsetProxyPluginEnv(t)
|
||||||
resetProxyPluginState()
|
|
||||||
proxyWarningOnce = sync.Once{}
|
proxyWarningOnce = sync.Once{}
|
||||||
|
|
||||||
for _, k := range proxyEnvKeys {
|
for _, k := range proxyEnvKeys {
|
||||||
@@ -70,25 +173,41 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
WarnIfProxied(&buf)
|
WarnIfProxied(&buf, true)
|
||||||
|
|
||||||
if buf.Len() != 0 {
|
if buf.Len() != 0 {
|
||||||
t.Errorf("expected no output when no proxy is set, got: %s", buf.String())
|
t.Errorf("expected no output when no proxy is set, got: %s", buf.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWarnIfProxied_SilentWhenDisabled verifies that LARK_CLI_NO_PROXY suppresses warnings.
|
func TestWarnIfProxied_SilentWhenNonInteractive(t *testing.T) {
|
||||||
|
proxyWarningOnce = sync.Once{}
|
||||||
|
|
||||||
|
// Non-interactive (interactive=false) mirrors agent / CI / piped invocations
|
||||||
|
// where stdin is not a TTY. The proxy warning must be suppressed so callers
|
||||||
|
// that parse stdout as JSON — often merging streams with `2>&1` — are not
|
||||||
|
// corrupted by a stray stderr line.
|
||||||
|
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
WarnIfProxied(&buf, false)
|
||||||
|
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Errorf("expected no warning in non-interactive mode, got: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWarnIfProxied_SilentWhenDisabled verifies that EnvNoProxy suppresses warnings.
|
||||||
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
unsetProxyPluginEnv(t)
|
unsetProxyPluginEnv(t)
|
||||||
resetProxyPluginState()
|
|
||||||
proxyWarningOnce = sync.Once{}
|
proxyWarningOnce = sync.Once{}
|
||||||
|
|
||||||
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
|
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
|
||||||
t.Setenv(EnvNoProxy, "1")
|
t.Setenv(EnvNoProxy, "1")
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
WarnIfProxied(&buf)
|
WarnIfProxied(&buf, true)
|
||||||
|
|
||||||
if buf.Len() != 0 {
|
if buf.Len() != 0 {
|
||||||
t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String())
|
t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String())
|
||||||
@@ -99,16 +218,15 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
|||||||
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
unsetProxyPluginEnv(t)
|
unsetProxyPluginEnv(t)
|
||||||
resetProxyPluginState()
|
|
||||||
proxyWarningOnce = sync.Once{}
|
proxyWarningOnce = sync.Once{}
|
||||||
|
|
||||||
t.Setenv("HTTP_PROXY", "http://proxy:1234")
|
t.Setenv("HTTP_PROXY", "http://proxy:1234")
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
WarnIfProxied(&buf)
|
WarnIfProxied(&buf, true)
|
||||||
first := buf.String()
|
first := buf.String()
|
||||||
|
|
||||||
WarnIfProxied(&buf)
|
WarnIfProxied(&buf, true)
|
||||||
second := buf.String()
|
second := buf.String()
|
||||||
|
|
||||||
if first == "" {
|
if first == "" {
|
||||||
@@ -137,7 +255,7 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
|
|||||||
t.Setenv(EnvNoProxy, "1")
|
t.Setenv(EnvNoProxy, "1")
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
WarnIfProxied(&buf)
|
WarnIfProxied(&buf, true)
|
||||||
out := buf.String()
|
out := buf.String()
|
||||||
|
|
||||||
if !strings.Contains(out, "127.0.0.1:3128") {
|
if !strings.Contains(out, "127.0.0.1:3128") {
|
||||||
@@ -156,7 +274,7 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestWarnIfProxied_ProxyPluginCustomCAWarns verifies that when a custom CA is
|
// TestWarnIfProxied_ProxyPluginCustomCAWarns verifies that when a custom CA is
|
||||||
// trusted, the warning surfaces the TLS-interception capability.
|
// trusted, the warning surfaces the TLS-interception capability (V3).
|
||||||
func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
|
func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
unsetProxyPluginEnv(t)
|
unsetProxyPluginEnv(t)
|
||||||
@@ -169,7 +287,7 @@ func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
|
|||||||
t.Cleanup(func() { proxyPluginStatus = old })
|
t.Cleanup(func() { proxyPluginStatus = old })
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
WarnIfProxied(&buf)
|
WarnIfProxied(&buf, true)
|
||||||
out := buf.String()
|
out := buf.String()
|
||||||
|
|
||||||
if !strings.Contains(out, "custom CA") {
|
if !strings.Contains(out, "custom CA") {
|
||||||
@@ -183,6 +301,25 @@ func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware
|
||||||
|
// transport (instead of a bare client that bypasses proxy plugin mode).
|
||||||
|
func TestNewHTTPClient(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
unsetProxyPluginEnv(t)
|
||||||
|
t.Setenv(EnvNoProxy, "")
|
||||||
|
|
||||||
|
c := NewHTTPClient(7 * time.Second)
|
||||||
|
if c.Transport == nil {
|
||||||
|
t.Fatal("NewHTTPClient transport is nil; want shared transport")
|
||||||
|
}
|
||||||
|
if c.Transport != SharedTransport() {
|
||||||
|
t.Errorf("NewHTTPClient transport = %v, want SharedTransport()", c.Transport)
|
||||||
|
}
|
||||||
|
if c.Timeout != 7*time.Second {
|
||||||
|
t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials verifies the plugin
|
// TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials verifies the plugin
|
||||||
// warning never leaks credentials embedded in the configured proxy address.
|
// warning never leaks credentials embedded in the configured proxy address.
|
||||||
func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
||||||
@@ -195,7 +332,7 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
|||||||
t.Cleanup(func() { proxyPluginStatus = old })
|
t.Cleanup(func() { proxyPluginStatus = old })
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
WarnIfProxied(&buf)
|
WarnIfProxied(&buf, true)
|
||||||
out := buf.String()
|
out := buf.String()
|
||||||
|
|
||||||
if strings.Contains(out, "s3cret") {
|
if strings.Contains(out, "s3cret") {
|
||||||
@@ -211,6 +348,8 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
|||||||
|
|
||||||
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
|
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
|
||||||
func TestRedactProxyURL(t *testing.T) {
|
func TestRedactProxyURL(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
unsetProxyPluginEnv(t)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
want string
|
want string
|
||||||
@@ -237,13 +376,12 @@ func TestRedactProxyURL(t *testing.T) {
|
|||||||
func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
|
func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
unsetProxyPluginEnv(t)
|
unsetProxyPluginEnv(t)
|
||||||
resetProxyPluginState()
|
|
||||||
proxyWarningOnce = sync.Once{}
|
proxyWarningOnce = sync.Once{}
|
||||||
|
|
||||||
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
|
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
WarnIfProxied(&buf)
|
WarnIfProxied(&buf, true)
|
||||||
|
|
||||||
out := buf.String()
|
out := buf.String()
|
||||||
if bytes.Contains([]byte(out), []byte("s3cret")) {
|
if bytes.Contains([]byte(out), []byte("s3cret")) {
|
||||||
@@ -25,10 +25,6 @@ var BaseAdvpermDisable = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "base-token", Desc: "base token", Required: true},
|
{Name: "base-token", Desc: "base token", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
baseHighRiskYesTip,
|
|
||||||
"Disabling advanced permissions invalidates existing custom roles; confirm the target Base before passing --yes.",
|
|
||||||
},
|
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return common.FlagErrorf("--base-token must not be blank")
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ var BaseAdvpermEnable = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "base-token", Desc: "base token", Required: true},
|
{Name: "base-token", Desc: "base token", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"Caller must be a Base admin; enable advanced permissions before creating or updating roles.",
|
|
||||||
},
|
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return common.FlagErrorf("--base-token must not be blank")
|
||||||
|
|||||||
@@ -24,11 +24,6 @@ var BaseBaseCopy = common.Shortcut{
|
|||||||
{Name: "without-content", Type: "bool", Desc: "copy structure only"},
|
{Name: "without-content", Type: "bool", Desc: "copy structure only"},
|
||||||
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"},
|
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
`Example: lark-cli base +base-copy --base-token <base_token> --name "Copy of Project Tracker"`,
|
|
||||||
"Use --without-content when the user wants only structure.",
|
|
||||||
"If copied as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.",
|
|
||||||
},
|
|
||||||
DryRun: dryRunBaseCopy,
|
DryRun: dryRunBaseCopy,
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return executeBaseCopy(runtime)
|
return executeBaseCopy(runtime)
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ var BaseBaseCreate = common.Shortcut{
|
|||||||
{Name: "folder-token", Desc: "folder token for destination"},
|
{Name: "folder-token", Desc: "folder token for destination"},
|
||||||
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"},
|
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
`Example: lark-cli base +base-create --name "Project Tracker" --time-zone Asia/Shanghai`,
|
|
||||||
"If created as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.",
|
|
||||||
},
|
|
||||||
DryRun: dryRunBaseCreate,
|
DryRun: dryRunBaseCreate,
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return executeBaseCreate(runtime)
|
return executeBaseCreate(runtime)
|
||||||
|
|||||||
@@ -20,12 +20,7 @@ var BaseDataQuery = common.Shortcut{
|
|||||||
AuthTypes: authTypes(),
|
AuthTypes: authTypes(),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
{Name: "dsl", Desc: "query JSON DSL; read lark-base-data-query-guide.md first, then lark-base-data-query.md for the full DSL SSOT", Required: true},
|
{Name: "dsl", Desc: "query JSON DSL (LiteQuery Protocol)", Required: true},
|
||||||
},
|
|
||||||
Tips: []string{
|
|
||||||
"Use +data-query for server-side aggregation, grouping, filtering, sorting, and Top N queries.",
|
|
||||||
"Read lark-base-data-query-guide.md for common fewshots; use lark-base-data-query.md only when the full DSL reference is needed.",
|
|
||||||
"`dimensions` and `measures` cannot both be empty.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
var dsl map[string]interface{}
|
var dsl map[string]interface{}
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "--json must be a JSON object") {
|
if !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "match the documented shape") {
|
if !strings.Contains(err.Error(), "lark-base skill") {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "array") {
|
if strings.Contains(err.Error(), "array") {
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ var BaseFormCreate = common.Shortcut{
|
|||||||
{Name: "name", Desc: "form name", Required: true},
|
{Name: "name", Desc: "form name", Required: true},
|
||||||
{Name: "description", Desc: `form description (plain text or markdown link like [text](https://example.com))`},
|
{Name: "description", Desc: `form description (plain text or markdown link like [text](https://example.com))`},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"Record the returned form_id; form question create/list/update/delete commands need it.",
|
|
||||||
},
|
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms").
|
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms").
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ var BaseFormDelete = common.Shortcut{
|
|||||||
{Name: "table-id", Desc: "table ID", Required: true},
|
{Name: "table-id", Desc: "table ID", Required: true},
|
||||||
{Name: "form-id", Desc: "form ID", Required: true},
|
{Name: "form-id", Desc: "form ID", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"Use +form-list or +form-get first when the form target is ambiguous.",
|
|
||||||
baseHighRiskYesTip,
|
|
||||||
},
|
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id").
|
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id").
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ var BaseFormsList = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||||
{Name: "table-id", Desc: "table ID", Required: true},
|
{Name: "table-id", Desc: "table ID", Required: true},
|
||||||
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request, max 100"},
|
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"},
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ var BaseFormQuestionsDelete = common.Shortcut{
|
|||||||
{Name: "form-id", Desc: "form ID", Required: true},
|
{Name: "form-id", Desc: "form ID", Required: true},
|
||||||
{Name: "question-ids", Desc: `JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`, Required: true},
|
{Name: "question-ids", Desc: `JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`, Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
baseHighRiskYesTip,
|
|
||||||
},
|
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ var BaseFormQuestionsList = common.Shortcut{
|
|||||||
{Name: "table-id", Desc: "table ID", Required: true},
|
{Name: "table-id", Desc: "table ID", Required: true},
|
||||||
{Name: "form-id", Desc: "form ID", Required: true},
|
{Name: "form-id", Desc: "form ID", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"Use returned question id values for +form-questions-update and +form-questions-delete.",
|
|
||||||
},
|
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ var BaseBaseGet = common.Shortcut{
|
|||||||
Scopes: []string{"base:app:read"},
|
Scopes: []string{"base:app:read"},
|
||||||
AuthTypes: authTypes(),
|
AuthTypes: authTypes(),
|
||||||
Flags: []common.Flag{baseTokenFlag(true)},
|
Flags: []common.Flag{baseTokenFlag(true)},
|
||||||
Tips: []string{
|
DryRun: dryRunBaseGet,
|
||||||
"Use a real Base token; workspace tokens and wiki tokens are not accepted by this command.",
|
|
||||||
},
|
|
||||||
DryRun: dryRunBaseGet,
|
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return executeBaseGet(runtime)
|
return executeBaseGet(runtime)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,12 +25,7 @@ var BaseRoleCreate = common.Shortcut{
|
|||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "base-token", Desc: "base token", Required: true},
|
{Name: "base-token", Desc: "base token", Required: true},
|
||||||
{Name: "json", Desc: "role config JSON; read lark-base-role-guide.md and role-config.md before constructing permissions", Required: true},
|
{Name: "json", Desc: `body JSON (AdvPermBaseRoleConfig), e.g. {"role_name":"Reviewer","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
|
||||||
},
|
|
||||||
Tips: []string{
|
|
||||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
|
||||||
"Use lark-base-role-guide.md as the entry guide and role-config.md as the role permission JSON SSOT.",
|
|
||||||
"Create supports custom_role only.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
|
|||||||
@@ -26,12 +26,6 @@ var BaseRoleDelete = common.Shortcut{
|
|||||||
{Name: "base-token", Desc: "base token", Required: true},
|
{Name: "base-token", Desc: "base token", Required: true},
|
||||||
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
|
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
baseHighRiskYesTip,
|
|
||||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
|
||||||
"Only custom roles can be deleted; system roles cannot be deleted.",
|
|
||||||
"Use +role-get first if the role target is ambiguous, then pass --yes to confirm deletion.",
|
|
||||||
},
|
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return common.FlagErrorf("--base-token must not be blank")
|
||||||
|
|||||||
@@ -27,10 +27,6 @@ var BaseRoleGet = common.Shortcut{
|
|||||||
{Name: "base-token", Desc: "base token", Required: true},
|
{Name: "base-token", Desc: "base token", Required: true},
|
||||||
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
|
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
|
||||||
"Use before +role-update to inspect the current full permission config.",
|
|
||||||
},
|
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return common.FlagErrorf("--base-token must not be blank")
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ var BaseRoleList = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "base-token", Desc: "base token", Required: true},
|
{Name: "base-token", Desc: "base token", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
|
||||||
"Returns role summaries; use +role-get for the full permission config.",
|
|
||||||
},
|
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return common.FlagErrorf("--base-token must not be blank")
|
||||||
|
|||||||
@@ -26,13 +26,7 @@ var BaseRoleUpdate = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "base-token", Desc: "base token", Required: true},
|
{Name: "base-token", Desc: "base token", Required: true},
|
||||||
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
|
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
|
||||||
{Name: "json", Desc: "delta role config JSON; read lark-base-role-guide.md and role-config.md before changing permissions", Required: true},
|
{Name: "json", Desc: `body JSON (delta AdvPermBaseRoleConfig), e.g. {"role_name":"New Name","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
|
||||||
},
|
|
||||||
Tips: []string{
|
|
||||||
baseHighRiskYesTip,
|
|
||||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
|
||||||
"Update is a delta merge: only changed fields are updated, others remain unchanged.",
|
|
||||||
"Use lark-base-role-guide.md as the entry guide and role-config.md as the role permission JSON SSOT.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func jsonInputTip(flagName string) string {
|
func jsonInputTip(flagName string) string {
|
||||||
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; for complex JSON/DSL, read the lark-base reference and match the documented shape", flagName)
|
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatJSONError(flagName string, target string, err error) error {
|
func formatJSONError(flagName string, target string, err error) error {
|
||||||
|
|||||||
@@ -198,25 +198,6 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBaseHighRiskShortcutsTipsGuideAgents(t *testing.T) {
|
|
||||||
for _, shortcut := range Shortcuts() {
|
|
||||||
if shortcut.Risk != "high-risk-write" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parent := &cobra.Command{Use: "base"}
|
|
||||||
shortcut.Mount(parent, &cmdutil.Factory{})
|
|
||||||
cmd := parent.Commands()[0]
|
|
||||||
flag := cmd.Flags().Lookup("yes")
|
|
||||||
if flag == nil {
|
|
||||||
t.Fatalf("%s missing --yes flag", shortcut.Command)
|
|
||||||
}
|
|
||||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
|
||||||
if !strings.Contains(tips, "pass --yes without asking again") {
|
|
||||||
t.Fatalf("%s tips missing agent guidance:\n%s", shortcut.Command, tips)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) {
|
func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) {
|
||||||
parent := &cobra.Command{Use: "base"}
|
parent := &cobra.Command{Use: "base"}
|
||||||
BaseFieldCreate.Mount(parent, &cmdutil.Factory{})
|
BaseFieldCreate.Mount(parent, &cmdutil.Factory{})
|
||||||
@@ -270,19 +251,20 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
|||||||
name: "record search",
|
name: "record search",
|
||||||
shortcut: BaseRecordSearch,
|
shortcut: BaseRecordSearch,
|
||||||
wantHelp: []string{
|
wantHelp: []string{
|
||||||
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
|
"requires keyword/search_fields",
|
||||||
"for keyword search only",
|
"optional select_fields/view_id/offset/limit",
|
||||||
"output format: markdown (default) | json",
|
"output format: markdown (default) | json",
|
||||||
},
|
},
|
||||||
wantTips: []string{
|
wantTips: []string{
|
||||||
"Happy path fields: keyword (string), search_fields",
|
`lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json`,
|
||||||
|
`"select_fields":["Name","Status"]`,
|
||||||
|
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"]`,
|
||||||
"search_fields length 1-20",
|
"search_fields length 1-20",
|
||||||
"limit range 1-200 defaults to 10",
|
"limit range 1-200 defaults to 10",
|
||||||
"view_id scopes search to records in that view",
|
"view_id scopes search to records in that view",
|
||||||
"Default output is markdown",
|
"Default output is markdown",
|
||||||
"only for keyword search",
|
"only for keyword search",
|
||||||
"lark-base record read SOP",
|
"lark-base record read SOP",
|
||||||
"inventing search JSON",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -329,401 +311,6 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
shortcut common.Shortcut
|
|
||||||
wantTips []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "dashboard list",
|
|
||||||
shortcut: BaseDashboardList,
|
|
||||||
wantTips: []string{
|
|
||||||
"Use returned dashboard_id values",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dashboard get",
|
|
||||||
shortcut: BaseDashboardGet,
|
|
||||||
wantTips: []string{
|
|
||||||
"block-level details",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dashboard create",
|
|
||||||
shortcut: BaseDashboardCreate,
|
|
||||||
wantTips: []string{
|
|
||||||
"Record the returned dashboard_id",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dashboard update",
|
|
||||||
shortcut: BaseDashboardUpdate,
|
|
||||||
wantTips: []string{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dashboard delete",
|
|
||||||
shortcut: BaseDashboardDelete,
|
|
||||||
wantTips: []string{
|
|
||||||
"lark-cli base +dashboard-delete --base-token <base_token> --dashboard-id <dashboard_id> --yes",
|
|
||||||
"also deletes its blocks",
|
|
||||||
"pass --yes",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dashboard arrange",
|
|
||||||
shortcut: BaseDashboardArrange,
|
|
||||||
wantTips: []string{
|
|
||||||
"not deterministic or position-specific",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dashboard block list",
|
|
||||||
shortcut: BaseDashboardBlockList,
|
|
||||||
wantTips: []string{
|
|
||||||
"lark-cli base +dashboard-block-list --base-token <base_token> --dashboard-id <dashboard_id>",
|
|
||||||
"Use returned block_id and type values",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dashboard block get",
|
|
||||||
shortcut: BaseDashboardBlockGet,
|
|
||||||
wantTips: []string{
|
|
||||||
"lark-cli base +dashboard-block-get --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id>",
|
|
||||||
"metadata such as name, type, layout, and data_config",
|
|
||||||
"computed chart result",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dashboard block get data",
|
|
||||||
shortcut: BaseDashboardBlockGetData,
|
|
||||||
wantTips: []string{
|
|
||||||
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
|
|
||||||
"does not need --dashboard-id",
|
|
||||||
"computed chart protocol JSON",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dashboard block create",
|
|
||||||
shortcut: BaseDashboardBlockCreate,
|
|
||||||
wantTips: []string{
|
|
||||||
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
|
|
||||||
`--type text --data-config '{"text":"# Sales Dashboard"}'`,
|
|
||||||
"+table-list and +field-list",
|
|
||||||
"not table_id or field_id",
|
|
||||||
"dashboard-block-data-config.md as the SSOT",
|
|
||||||
"do not invent data_config from natural language",
|
|
||||||
"sequentially",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dashboard block update",
|
|
||||||
shortcut: BaseDashboardBlockUpdate,
|
|
||||||
wantTips: []string{
|
|
||||||
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,
|
|
||||||
`--data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`,
|
|
||||||
"dashboard-block-data-config.md as the SSOT",
|
|
||||||
"do not invent data_config from natural language",
|
|
||||||
"Block type cannot be changed",
|
|
||||||
"top-level keys",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dashboard block delete",
|
|
||||||
shortcut: BaseDashboardBlockDelete,
|
|
||||||
wantTips: []string{
|
|
||||||
"lark-cli base +dashboard-block-delete --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --yes",
|
|
||||||
"pass --yes",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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 TestBaseWorkflowHelpGuidesAgents(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
shortcut common.Shortcut
|
|
||||||
wantTips []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "workflow list",
|
|
||||||
shortcut: BaseWorkflowList,
|
|
||||||
wantTips: []string{
|
|
||||||
"workflow_id values with wkf prefix",
|
|
||||||
"auto-paginates",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "workflow get",
|
|
||||||
shortcut: BaseWorkflowGet,
|
|
||||||
wantTips: []string{
|
|
||||||
"workflow-id must start with wkf",
|
|
||||||
"steps may be an empty array",
|
|
||||||
"Use +workflow-get before +workflow-update",
|
|
||||||
"lark-base-workflow-schema.md",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "workflow create",
|
|
||||||
shortcut: BaseWorkflowCreate,
|
|
||||||
wantTips: []string{
|
|
||||||
"lark-cli base +workflow-create --base-token <base_token> --json @workflow.json",
|
|
||||||
"client_token is required",
|
|
||||||
"New workflows are created disabled",
|
|
||||||
"+table-list and +field-list",
|
|
||||||
"Step ids must be unique",
|
|
||||||
"lark-base-workflow-guide.md as the entry guide",
|
|
||||||
"lark-base-workflow-schema.md as the steps JSON SSOT",
|
|
||||||
"do not invent steps[].type/data/next/children from natural language",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "workflow update",
|
|
||||||
shortcut: BaseWorkflowUpdate,
|
|
||||||
wantTips: []string{
|
|
||||||
"lark-cli base +workflow-update --base-token <base_token> --workflow-id <workflow_id> --json @workflow.json",
|
|
||||||
"PUT uses full replacement semantics",
|
|
||||||
"Use +workflow-get first",
|
|
||||||
"keep title/status/steps fields",
|
|
||||||
"workflow-id must start with wkf",
|
|
||||||
"Updating does not enable or disable",
|
|
||||||
"do not invent steps[].type/data/next/children from natural language",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "workflow enable",
|
|
||||||
shortcut: BaseWorkflowEnable,
|
|
||||||
wantTips: []string{
|
|
||||||
"workflow-id must start with wkf",
|
|
||||||
"does not modify steps",
|
|
||||||
"New workflows are created disabled",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "workflow disable",
|
|
||||||
shortcut: BaseWorkflowDisable,
|
|
||||||
wantTips: []string{
|
|
||||||
"workflow-id must start with wkf",
|
|
||||||
"does not delete the workflow or its steps",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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 TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
shortcut common.Shortcut
|
|
||||||
wantHelp []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "table create fields",
|
|
||||||
shortcut: BaseTableCreate,
|
|
||||||
wantHelp: []string{
|
|
||||||
`field JSON array for create, e.g. [{"name":"Title","type":"text"}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "view set filter",
|
|
||||||
shortcut: BaseViewSetFilter,
|
|
||||||
wantHelp: []string{
|
|
||||||
`filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "view set sort",
|
|
||||||
shortcut: BaseViewSetSort,
|
|
||||||
wantHelp: []string{
|
|
||||||
`sort_config JSON object, e.g. {"sort_config":[{"field":"Priority","desc":true}]}`,
|
|
||||||
`use {"sort_config":[]} to clear`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "view set group",
|
|
||||||
shortcut: BaseViewSetGroup,
|
|
||||||
wantHelp: []string{
|
|
||||||
`group JSON object with group_config array, e.g. {"group_config":[{"field":"Status","desc":false}]}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "view set card",
|
|
||||||
shortcut: BaseViewSetCard,
|
|
||||||
wantHelp: []string{
|
|
||||||
`card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "view set timebar",
|
|
||||||
shortcut: BaseViewSetTimebar,
|
|
||||||
wantHelp: []string{
|
|
||||||
`timebar JSON object with start_time, end_time, title, e.g. {"start_time":"Start Date","end_time":"End Date","title":"Name"}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "view set visible fields",
|
|
||||||
shortcut: BaseViewSetVisibleFields,
|
|
||||||
wantHelp: []string{
|
|
||||||
`visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "form question delete",
|
|
||||||
shortcut: BaseFormQuestionsDelete,
|
|
||||||
wantHelp: []string{
|
|
||||||
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "record search json",
|
|
||||||
shortcut: BaseRecordSearch,
|
|
||||||
wantHelp: []string{
|
|
||||||
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "record upsert json",
|
|
||||||
shortcut: BaseRecordUpsert,
|
|
||||||
wantHelp: []string{
|
|
||||||
`record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "record batch create json",
|
|
||||||
shortcut: BaseRecordBatchCreate,
|
|
||||||
wantHelp: []string{
|
|
||||||
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "record batch update json",
|
|
||||||
shortcut: BaseRecordBatchUpdate,
|
|
||||||
wantHelp: []string{
|
|
||||||
`batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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]
|
|
||||||
|
|
||||||
help := cmd.Flags().FlagUsages()
|
|
||||||
for _, want := range tt.wantHelp {
|
|
||||||
if !strings.Contains(help, want) {
|
|
||||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
shortcut common.Shortcut
|
|
||||||
wantTips []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "record upsert",
|
|
||||||
shortcut: BaseRecordUpsert,
|
|
||||||
wantTips: []string{
|
|
||||||
"Happy path JSON is a top-level field map",
|
|
||||||
"Without --record-id this creates a record",
|
|
||||||
"does not auto-upsert by business key",
|
|
||||||
"use +field-list to confirm real writable fields",
|
|
||||||
"do not write system fields, formula, lookup, or attachment fields",
|
|
||||||
"CellValue happy path: text/phone/url",
|
|
||||||
"select -> \"Todo\"",
|
|
||||||
"multi-select -> [\"Tag A\",\"Tag B\"]",
|
|
||||||
"datetime -> \"2026-03-24 10:00:00\"",
|
|
||||||
"checkbox -> true/false",
|
|
||||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
|
||||||
`location uses {"lng":116.397428,"lat":39.90923}`,
|
|
||||||
"Do not guess user/chat/linked-record IDs or location coordinates",
|
|
||||||
"lark-base-cell-value.md",
|
|
||||||
"do not invent values for fields not covered by the happy path",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "record batch create",
|
|
||||||
shortcut: BaseRecordBatchCreate,
|
|
||||||
wantTips: []string{
|
|
||||||
"Happy path fields: fields is the column order",
|
|
||||||
"rows is an array of row arrays",
|
|
||||||
"may use null for empty cells",
|
|
||||||
"use +field-list to confirm real writable fields",
|
|
||||||
"Batch create supports max 200 rows per call",
|
|
||||||
"CellValue happy path: text/phone/url",
|
|
||||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
|
||||||
"lark-base-cell-value.md",
|
|
||||||
"do not invent values for fields not covered by the happy path",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "record batch update",
|
|
||||||
shortcut: BaseRecordBatchUpdate,
|
|
||||||
wantTips: []string{
|
|
||||||
"Happy path fields: record_id_list is the target record IDs",
|
|
||||||
"patch is a field map applied unchanged to every target record",
|
|
||||||
"Do not use +record-batch-update for per-row different values",
|
|
||||||
"use +field-list to confirm real writable fields",
|
|
||||||
"Batch update supports max 200 records per call",
|
|
||||||
"CellValue happy path: text/phone/url",
|
|
||||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
|
||||||
"lark-base-cell-value.md",
|
|
||||||
"do not invent values for fields not covered by the happy path",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||||
parent := &cobra.Command{Use: "base"}
|
parent := &cobra.Command{Use: "base"}
|
||||||
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
|
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
|
||||||
@@ -741,7 +328,7 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
|||||||
|
|
||||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||||
wantTips := []string{
|
wantTips := []string{
|
||||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||||
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
|
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
|
||||||
"full field-definition PUT semantics",
|
"full field-definition PUT semantics",
|
||||||
"Read the current field first with +field-get",
|
"Read the current field first with +field-get",
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ var BaseDashboardArrange = common.Shortcut{
|
|||||||
dashboardIDFlag(true),
|
dashboardIDFlag(true),
|
||||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard.",
|
|
||||||
},
|
|
||||||
DryRun: dryRunDashboardArrange,
|
DryRun: dryRunDashboardArrange,
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return executeDashboardArrange(runtime)
|
return executeDashboardArrange(runtime)
|
||||||
|
|||||||
@@ -25,19 +25,10 @@ var BaseDashboardBlockCreate = common.Shortcut{
|
|||||||
dashboardIDFlag(true),
|
dashboardIDFlag(true),
|
||||||
{Name: "name", Desc: "block name", Required: true},
|
{Name: "name", Desc: "block name", Required: true},
|
||||||
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true},
|
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true},
|
||||||
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
|
{Name: "data-config", Desc: "data config JSON object (table_name, series, count_all, group_by, filter, etc.)"},
|
||||||
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
|
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
|
|
||||||
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Dashboard Note" --type text --data-config '{"text":"# Sales Dashboard"}'`,
|
|
||||||
"Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.",
|
|
||||||
"data_config uses table and field names, not table_id or field_id.",
|
|
||||||
"Read dashboard-block-data-config.md as the SSOT for chart templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
|
|
||||||
"Record the returned block_id; block update/delete/get-data commands need it.",
|
|
||||||
"Create dashboard blocks sequentially; do not parallelize multiple block creates for the same dashboard.",
|
|
||||||
},
|
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
pc := newParseCtx(runtime)
|
pc := newParseCtx(runtime)
|
||||||
if runtime.Bool("no-validate") {
|
if runtime.Bool("no-validate") {
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ var BaseDashboardBlockDelete = common.Shortcut{
|
|||||||
dashboardIDFlag(true),
|
dashboardIDFlag(true),
|
||||||
blockIDFlag(true),
|
blockIDFlag(true),
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"lark-cli base +dashboard-block-delete --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --yes",
|
|
||||||
baseHighRiskYesTip,
|
|
||||||
},
|
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id").
|
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id").
|
||||||
|
|||||||
@@ -24,11 +24,6 @@ var BaseDashboardBlockGet = common.Shortcut{
|
|||||||
blockIDFlag(true),
|
blockIDFlag(true),
|
||||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"lark-cli base +dashboard-block-get --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id>",
|
|
||||||
"Use this command for block metadata such as name, type, layout, and data_config.",
|
|
||||||
"Use +dashboard-block-get-data when you need the computed chart result instead of metadata.",
|
|
||||||
},
|
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
params := map[string]interface{}{}
|
params := map[string]interface{}{}
|
||||||
if uid := strings.TrimSpace(runtime.Str("user-id-type")); uid != "" {
|
if uid := strings.TrimSpace(runtime.Str("user-id-type")); uid != "" {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ var BaseDashboardBlockGetData = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
|
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
|
||||||
"This command does not need --dashboard-id.",
|
|
||||||
"Use +dashboard-block-get first when you need block metadata like name, type, or data_config.",
|
"Use +dashboard-block-get first when you need block metadata like name, type, or data_config.",
|
||||||
"This command returns computed chart protocol JSON directly, not wrapped block metadata.",
|
"This command returns computed chart protocol JSON directly, not wrapped block metadata.",
|
||||||
"Text blocks do not have computed chart data; this shortcut is for chart/statistics blocks.",
|
"Text blocks do not have computed chart data; this shortcut is for chart/statistics blocks.",
|
||||||
|
|||||||
@@ -21,13 +21,9 @@ var BaseDashboardBlockList = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
dashboardIDFlag(true),
|
dashboardIDFlag(true),
|
||||||
{Name: "page-size", Desc: "page size, default 20, max 100"},
|
{Name: "page-size", Desc: "page size (max 100)"},
|
||||||
{Name: "page-token", Desc: "pagination token"},
|
{Name: "page-token", Desc: "pagination token"},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"lark-cli base +dashboard-block-list --base-token <base_token> --dashboard-id <dashboard_id>",
|
|
||||||
"Use returned block_id and type values for +dashboard-block-get/update/delete/get-data.",
|
|
||||||
},
|
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
params := map[string]interface{}{}
|
params := map[string]interface{}{}
|
||||||
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {
|
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {
|
||||||
|
|||||||
@@ -24,18 +24,10 @@ var BaseDashboardBlockUpdate = common.Shortcut{
|
|||||||
dashboardIDFlag(true),
|
dashboardIDFlag(true),
|
||||||
blockIDFlag(true),
|
blockIDFlag(true),
|
||||||
{Name: "name", Desc: "new block name"},
|
{Name: "name", Desc: "new block name"},
|
||||||
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
|
{Name: "data-config", Desc: "data config JSON. For chart types: table_name, series|count_all, group_by, filter. For text type: text (markdown supported). See dashboard-block-data-config.md for details."},
|
||||||
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
|
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,
|
|
||||||
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`,
|
|
||||||
"Read dashboard-block-data-config.md as the SSOT for data_config templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
|
|
||||||
"Use +dashboard-block-get first to inspect the current data_config before replacing nested values.",
|
|
||||||
"Block type cannot be changed; delete and recreate the block to change chart type.",
|
|
||||||
"data_config update merges top-level keys, but each provided key is replaced as a whole.",
|
|
||||||
},
|
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
pc := newParseCtx(runtime)
|
pc := newParseCtx(runtime)
|
||||||
if runtime.Bool("no-validate") {
|
if runtime.Bool("no-validate") {
|
||||||
|
|||||||
@@ -20,10 +20,7 @@ var BaseDashboardCreate = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
{Name: "name", Desc: "dashboard name", Required: true},
|
{Name: "name", Desc: "dashboard name", Required: true},
|
||||||
{Name: "theme-style", Desc: "theme style, defaults to platform default when omitted"},
|
{Name: "theme-style", Desc: "theme style"},
|
||||||
},
|
|
||||||
Tips: []string{
|
|
||||||
"Record the returned dashboard_id; dashboard block create/get/update/delete/arrange commands need it.",
|
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
body := map[string]interface{}{}
|
body := map[string]interface{}{}
|
||||||
|
|||||||
@@ -21,11 +21,6 @@ var BaseDashboardDelete = common.Shortcut{
|
|||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
dashboardIDFlag(true),
|
dashboardIDFlag(true),
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"lark-cli base +dashboard-delete --base-token <base_token> --dashboard-id <dashboard_id> --yes",
|
|
||||||
"Deleting a dashboard also deletes its blocks and cannot be recovered.",
|
|
||||||
baseHighRiskYesTip,
|
|
||||||
},
|
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").
|
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ var BaseDashboardGet = common.Shortcut{
|
|||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
dashboardIDFlag(true),
|
dashboardIDFlag(true),
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"Use +dashboard-block-list or +dashboard-block-get when you need block-level details.",
|
|
||||||
},
|
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").
|
GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").
|
||||||
|
|||||||
@@ -20,12 +20,9 @@ var BaseDashboardList = common.Shortcut{
|
|||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
{Name: "page-size", Desc: "page size, max 100"},
|
{Name: "page-size", Desc: "page size (max 100)"},
|
||||||
{Name: "page-token", Desc: "pagination token"},
|
{Name: "page-token", Desc: "pagination token"},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"Use returned dashboard_id values for +dashboard-get, +dashboard-block-list, and +dashboard-block-create.",
|
|
||||||
},
|
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
params := map[string]interface{}{}
|
params := map[string]interface{}{}
|
||||||
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {
|
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ var BaseDashboardUpdate = common.Shortcut{
|
|||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
dashboardIDFlag(true),
|
dashboardIDFlag(true),
|
||||||
{Name: "name", Desc: "new dashboard name"},
|
{Name: "name", Desc: "new dashboard name"},
|
||||||
{Name: "theme-style", Desc: "theme style, leave empty to keep current theme"},
|
{Name: "theme-style", Desc: "theme style"},
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
body := map[string]interface{}{}
|
body := map[string]interface{}{}
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ var BaseFieldCreate = common.Shortcut{
|
|||||||
{Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true},
|
{Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
`Example text: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"text"}'`,
|
`Example: --json '{"name":"Status","type":"text"}'`,
|
||||||
`Example select: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
|
|
||||||
"Agent hint: use the lark-base skill's field-create guide for usage and limits.",
|
"Agent hint: use the lark-base skill's field-create guide for usage and limits.",
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
|||||||
@@ -17,11 +17,7 @@ var BaseFieldDelete = common.Shortcut{
|
|||||||
Scopes: []string{"base:field:delete"},
|
Scopes: []string{"base:field:delete"},
|
||||||
AuthTypes: authTypes(),
|
AuthTypes: authTypes(),
|
||||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
|
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
|
||||||
Tips: []string{
|
DryRun: dryRunFieldDelete,
|
||||||
baseHighRiskYesTip,
|
|
||||||
`Example: lark-cli base +field-delete --base-token <base_token> --table-id <table_id> --field-id "Status" --yes`,
|
|
||||||
},
|
|
||||||
DryRun: dryRunFieldDelete,
|
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return executeFieldDelete(runtime)
|
return executeFieldDelete(runtime)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,12 +17,7 @@ var BaseFieldGet = common.Shortcut{
|
|||||||
Scopes: []string{"base:field:read"},
|
Scopes: []string{"base:field:read"},
|
||||||
AuthTypes: authTypes(),
|
AuthTypes: authTypes(),
|
||||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
|
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
|
||||||
Tips: []string{
|
DryRun: dryRunFieldGet,
|
||||||
`Example: lark-cli base +field-get --base-token <base_token> --table-id <table_id> --field-id "Status"`,
|
|
||||||
"field-id accepts a field ID (fld...) or the field name from the current table.",
|
|
||||||
"Returns full field configuration; use it as the baseline before +field-update.",
|
|
||||||
},
|
|
||||||
DryRun: dryRunFieldGet,
|
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return executeFieldGet(runtime)
|
return executeFieldGet(runtime)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ var BaseFieldList = common.Shortcut{
|
|||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
|
||||||
},
|
},
|
||||||
DryRun: dryRunFieldList,
|
DryRun: dryRunFieldList,
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
|||||||
@@ -22,11 +22,7 @@ var BaseFieldSearchOptions = common.Shortcut{
|
|||||||
fieldRefFlag(true),
|
fieldRefFlag(true),
|
||||||
{Name: "keyword", Desc: "keyword for option query"},
|
{Name: "keyword", Desc: "keyword for option query"},
|
||||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||||
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size, default 30"},
|
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size"},
|
||||||
},
|
|
||||||
Tips: []string{
|
|
||||||
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
|
|
||||||
"Use only for fields with options, such as select or multi-select fields.",
|
|
||||||
},
|
},
|
||||||
DryRun: dryRunFieldSearchOptions,
|
DryRun: dryRunFieldSearchOptions,
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
|||||||
@@ -24,9 +24,8 @@ var BaseFieldUpdate = common.Shortcut{
|
|||||||
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
|
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
baseHighRiskYesTip,
|
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||||
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
|
||||||
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
|
|
||||||
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
|
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
|
||||||
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
|
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
|
||||||
"Formula and lookup updates require reading the corresponding guide first.",
|
"Formula and lookup updates require reading the corresponding guide first.",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestParseHelpers(t *testing.T) {
|
|||||||
if err != nil || obj["name"] != "demo" {
|
if err != nil || obj["name"] != "demo" {
|
||||||
t.Fatalf("obj=%v err=%v", obj, err)
|
t.Fatalf("obj=%v err=%v", obj, err)
|
||||||
}
|
}
|
||||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "match the documented shape") || strings.Contains(err.Error(), "array") {
|
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
}
|
}
|
||||||
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||||
@@ -66,7 +66,7 @@ func TestParseHelpers(t *testing.T) {
|
|||||||
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
|
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
}
|
}
|
||||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "complex JSON/DSL") {
|
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
|
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
|
||||||
@@ -334,11 +334,11 @@ func TestJSONInputHelpers(t *testing.T) {
|
|||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
}
|
}
|
||||||
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
|
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
|
||||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "complex JSON/DSL") {
|
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
|
||||||
t.Fatalf("syntaxErr=%v", syntaxErr)
|
t.Fatalf("syntaxErr=%v", syntaxErr)
|
||||||
}
|
}
|
||||||
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
|
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
|
||||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "complex JSON/DSL") {
|
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") {
|
||||||
t.Fatalf("typeErr=%v", typeErr)
|
t.Fatalf("typeErr=%v", typeErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package base
|
|
||||||
|
|
||||||
const baseHighRiskYesTip = "This is a high-risk write command. If the user explicitly requested it and the target is unambiguous, pass --yes without asking again."
|
|
||||||
@@ -19,14 +19,13 @@ var BaseRecordBatchCreate = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
{Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
|
{Name: "json", Desc: "batch create JSON object", Required: true},
|
||||||
|
},
|
||||||
|
Tips: []string{
|
||||||
|
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
|
||||||
|
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
|
||||||
|
"Agent hint: use lark-base-cell-value.md as the source of truth for each CellValue.",
|
||||||
},
|
},
|
||||||
Tips: append([]string{
|
|
||||||
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
|
|
||||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
|
||||||
"Batch create supports max 200 rows per call.",
|
|
||||||
"Use the record-batch-create guide for command limits and edge cases.",
|
|
||||||
}, recordCellValueHappyPathTips...),
|
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateRecordJSON(runtime)
|
return validateRecordJSON(runtime)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,14 +19,13 @@ var BaseRecordBatchUpdate = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
{Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true},
|
{Name: "json", Desc: "batch update JSON object", Required: true},
|
||||||
|
},
|
||||||
|
Tips: []string{
|
||||||
|
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
|
||||||
|
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
|
||||||
|
"Agent hint: use lark-base-cell-value.md as the source of truth for each patch CellValue.",
|
||||||
},
|
},
|
||||||
Tips: append([]string{
|
|
||||||
"Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.",
|
|
||||||
"Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.",
|
|
||||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
|
||||||
"Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.",
|
|
||||||
}, recordCellValueHappyPathTips...),
|
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateRecordJSON(runtime)
|
return validateRecordJSON(runtime)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ var BaseRecordDelete = common.Shortcut{
|
|||||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
baseHighRiskYesTip,
|
|
||||||
`Example: lark-cli base +record-delete --base-token <base_token> --table-id <table_id> --record-id <record_id_1> --record-id <record_id_2> --yes`,
|
|
||||||
},
|
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateRecordSelection(runtime)
|
return validateRecordSelection(runtime)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,11 +21,7 @@ var BaseRecordHistoryList = common.Shortcut{
|
|||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
recordRefFlag(true),
|
recordRefFlag(true),
|
||||||
{Name: "max-version", Type: "int", Desc: "max version for next page"},
|
{Name: "max-version", Type: "int", Desc: "max version for next page"},
|
||||||
{Name: "page-size", Type: "int", Default: "30", Desc: "pagination size, max 50"},
|
{Name: "page-size", Type: "int", Default: "30", Desc: "pagination size"},
|
||||||
},
|
|
||||||
Tips: []string{
|
|
||||||
`Example: lark-cli base +record-history-list --base-token <base_token> --table-id <table_id> --record-id <record_id>`,
|
|
||||||
"This reads one record's history only; it is not a table-wide audit scan.",
|
|
||||||
},
|
},
|
||||||
DryRun: dryRunRecordHistoryList,
|
DryRun: dryRunRecordHistoryList,
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
|||||||
@@ -15,13 +15,6 @@ import (
|
|||||||
const maxRecordSelectionCount = 200
|
const maxRecordSelectionCount = 200
|
||||||
const maxBatchGetSelectFieldCount = 100
|
const maxBatchGetSelectFieldCount = 100
|
||||||
|
|
||||||
var recordCellValueHappyPathTips = []string{
|
|
||||||
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
|
|
||||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
|
|
||||||
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
|
|
||||||
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",
|
|
||||||
}
|
|
||||||
|
|
||||||
type recordSelection struct {
|
type recordSelection struct {
|
||||||
recordIDs []string
|
recordIDs []string
|
||||||
selectFields []string
|
selectFields []string
|
||||||
|
|||||||
@@ -20,15 +20,17 @@ var BaseRecordSearch = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
{Name: "json", Desc: `record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}; for keyword search only`, Required: true},
|
{Name: "json", Desc: `record search JSON object; requires keyword/search_fields, optional select_fields/view_id/offset/limit`, Required: true},
|
||||||
recordReadFormatFlag(),
|
recordReadFormatFlag(),
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
`Happy path fields: keyword (string), search_fields (1-20 field names/ids), select_fields (optional projection, <=50), view_id (optional), offset (default 0), limit (default 10, range 1-200).`,
|
`Example: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json '{"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}'`,
|
||||||
|
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"],"select_fields":["<field_id_or_name>"],"view_id":"<view_id_or_name>","offset":0,"limit":10}.`,
|
||||||
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.",
|
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.",
|
||||||
"view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.",
|
"view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.",
|
||||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||||
"Use +record-search only for keyword search; for structured conditions, sorting, Top/Bottom N, or global conclusions, follow the lark-base record read SOP instead of inventing search JSON.",
|
"Use +record-search only for keyword search; use a filtered view plus +record-list for structured conditions.",
|
||||||
|
"Agent hint: follow the lark-base record read SOP for record read routing and limits.",
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if err := validateRecordReadFormat(runtime); err != nil {
|
if err := validateRecordReadFormat(runtime); err != nil {
|
||||||
|
|||||||
@@ -22,9 +22,8 @@ var BaseRecordShareLinkCreate = common.Shortcut{
|
|||||||
{Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)", Required: true},
|
{Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
`Example: lark-cli base +record-share-link-create --base-token <base_token> --table-id <table_id> --record-ids <record_id>`,
|
`Single record: --base-token xxx --table-id tblxxx --record-ids recxxx`,
|
||||||
"Max 100 record IDs per call; duplicate IDs are ignored.",
|
`Multiple records: --base-token xxx --table-id tblxxx --record-ids rec001,rec002,rec003`,
|
||||||
"Output record_share_links maps record_id to URL; records without permission or missing records may be absent.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateRecordShareBatch(runtime)
|
return validateRecordShareBatch(runtime)
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ var BaseRecordRemoveAttachment = common.Shortcut{
|
|||||||
{Name: "file-token", Type: "string_array", Desc: "attachment file_token to remove from the target cell; repeat to remove multiple attachments; max 50 tokens", Required: true},
|
{Name: "file-token", Type: "string_array", Desc: "attachment file_token to remove from the target cell; repeat to remove multiple attachments; max 50 tokens", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
baseHighRiskYesTip,
|
|
||||||
`Example: lark-cli base +record-remove-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file-token <file_token> --yes`,
|
`Example: lark-cli base +record-remove-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file-token <file_token> --yes`,
|
||||||
`Repeat --file-token to remove multiple attachments from the same cell in one call.`,
|
`Repeat --file-token to remove multiple attachments from the same cell in one call.`,
|
||||||
`This is a high-risk write command and requires --yes.`,
|
`This is a high-risk write command and requires --yes.`,
|
||||||
|
|||||||
@@ -20,14 +20,12 @@ var BaseRecordUpsert = common.Shortcut{
|
|||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
recordRefFlag(false),
|
recordRefFlag(false),
|
||||||
{Name: "json", Desc: `record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`, Required: true},
|
{Name: "json", Desc: "record JSON object: Map<FieldNameOrID, CellValue>", Required: true},
|
||||||
|
},
|
||||||
|
Tips: []string{
|
||||||
|
`Example: --json '{"Name":"Alice"}'`,
|
||||||
|
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
|
||||||
},
|
},
|
||||||
Tips: append([]string{
|
|
||||||
"Happy path JSON is a top-level field map: each key is a real field name or field ID, each value is that field's CellValue.",
|
|
||||||
"Without --record-id this creates a record; with --record-id this updates that record. It does not auto-upsert by business key.",
|
|
||||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
|
||||||
"Use the record-upsert guide for command limits and edge cases.",
|
|
||||||
}, recordCellValueHappyPathTips...),
|
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateRecordJSON(runtime)
|
return validateRecordJSON(runtime)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,11 +20,7 @@ var BaseTableCreate = common.Shortcut{
|
|||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
{Name: "name", Desc: "table name", Required: true},
|
{Name: "name", Desc: "table name", Required: true},
|
||||||
{Name: "view", Desc: "view JSON object/array for create"},
|
{Name: "view", Desc: "view JSON object/array for create"},
|
||||||
{Name: "fields", Desc: `field JSON array for create, e.g. [{"name":"Title","type":"text"},{"name":"Status","type":"select","options":[{"name":"Todo"},{"name":"Done"}]}]`},
|
{Name: "fields", Desc: "field JSON array for create"},
|
||||||
},
|
|
||||||
Tips: []string{
|
|
||||||
"Before using --fields, read lark-base-field-json.md or rely on the same field JSON shape used by +field-create; do not invent field properties.",
|
|
||||||
"The first --fields item replaces the default field.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateTableCreate(runtime)
|
return validateTableCreate(runtime)
|
||||||
|
|||||||
@@ -17,12 +17,7 @@ var BaseTableDelete = common.Shortcut{
|
|||||||
Scopes: []string{"base:table:delete"},
|
Scopes: []string{"base:table:delete"},
|
||||||
AuthTypes: authTypes(),
|
AuthTypes: authTypes(),
|
||||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)},
|
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)},
|
||||||
Tips: []string{
|
DryRun: dryRunTableDelete,
|
||||||
`Example: lark-cli base +table-delete --base-token <base_token> --table-id "Old Tasks" --yes`,
|
|
||||||
"table-id accepts a table ID (tbl...) or the table name in the current Base.",
|
|
||||||
baseHighRiskYesTip,
|
|
||||||
},
|
|
||||||
DryRun: dryRunTableDelete,
|
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return executeTableDelete(runtime)
|
return executeTableDelete(runtime)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,11 +17,7 @@ var BaseTableGet = common.Shortcut{
|
|||||||
Scopes: []string{"base:table:read", "base:field:read", "base:view:read"},
|
Scopes: []string{"base:table:read", "base:field:read", "base:view:read"},
|
||||||
AuthTypes: authTypes(),
|
AuthTypes: authTypes(),
|
||||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)},
|
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)},
|
||||||
Tips: []string{
|
DryRun: dryRunTableGet,
|
||||||
`Example: lark-cli base +table-get --base-token <base_token> --table-id "Tasks"`,
|
|
||||||
"table-id accepts a table ID (tbl...) or the table name in the current Base.",
|
|
||||||
},
|
|
||||||
DryRun: dryRunTableGet,
|
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return executeTableGet(runtime)
|
return executeTableGet(runtime)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ var BaseTableList = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||||
{Name: "limit", Type: "int", Default: "50", Desc: "pagination size, range 1-100"},
|
{Name: "limit", Type: "int", Default: "50", Desc: "pagination limit"},
|
||||||
},
|
},
|
||||||
DryRun: dryRunTableList,
|
DryRun: dryRunTableList,
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
|||||||
@@ -19,13 +19,11 @@ var BaseViewCreate = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
{Name: "json", Desc: "view JSON object/array; type defaults to grid; type range: grid, kanban, gallery, calendar, gantt", Required: true},
|
{Name: "json", Desc: "view JSON object/array", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
`Example: lark-cli base +view-create --base-token <base_token> --table-id <table_id> --json '{"name":"Main","type":"grid"}'`,
|
`Example: --json '{"name":"Main","type":"grid"}'`,
|
||||||
`Minimal: --json '{"name":"Main"}' creates a grid view.`,
|
"Agent hint: use the lark-base skill's view-create guide for usage and limits.",
|
||||||
"Do not pass form as a view type; form views are managed through form commands.",
|
|
||||||
`Use +view-set-visible-fields after creation when the user needs a specific field order or visibility.`,
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateViewCreate(runtime)
|
return validateViewCreate(runtime)
|
||||||
|
|||||||
@@ -17,11 +17,7 @@ var BaseViewDelete = common.Shortcut{
|
|||||||
Scopes: []string{"base:view:write_only"},
|
Scopes: []string{"base:view:write_only"},
|
||||||
AuthTypes: authTypes(),
|
AuthTypes: authTypes(),
|
||||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)},
|
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)},
|
||||||
Tips: []string{
|
DryRun: dryRunViewDelete,
|
||||||
baseHighRiskYesTip,
|
|
||||||
`Example: lark-cli base +view-delete --base-token <base_token> --table-id <table_id> --view-id "Old View" --yes`,
|
|
||||||
},
|
|
||||||
DryRun: dryRunViewDelete,
|
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return executeViewDelete(runtime)
|
return executeViewDelete(runtime)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ var BaseViewList = common.Shortcut{
|
|||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
|
||||||
},
|
},
|
||||||
DryRun: dryRunViewList,
|
DryRun: dryRunViewList,
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
|||||||
@@ -20,12 +20,11 @@ var BaseViewSetCard = common.Shortcut{
|
|||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
viewRefFlag(true),
|
viewRefFlag(true),
|
||||||
{Name: "json", Desc: `card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`, Required: true},
|
{Name: "json", Desc: "card JSON object", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
"Supported view types: gallery, kanban.",
|
`Example: --json '{"cover_field":"fldCover"}'`,
|
||||||
"cover_field should be an attachment field id/name, or null to clear.",
|
"Agent hint: use the lark-base skill's view-set-card guide for usage and limits.",
|
||||||
"Use +view-get-card first when updating an existing card view configuration.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateViewJSONObject(runtime)
|
return validateViewJSONObject(runtime)
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ var BaseViewSetFilter = common.Shortcut{
|
|||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
viewRefFlag(true),
|
viewRefFlag(true),
|
||||||
{Name: "json", Desc: `filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`, Required: true},
|
{Name: "json", Desc: "filter JSON object", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
|
`Example: --json '{"logic":"and","conditions":[["fldStatus","==","Todo"]]}'`,
|
||||||
"Agent hint: use the lark-base skill's view-set-filter guide for usage and limits.",
|
"Agent hint: use the lark-base skill's view-set-filter guide for usage and limits.",
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ var BaseViewSetGroup = common.Shortcut{
|
|||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
viewRefFlag(true),
|
viewRefFlag(true),
|
||||||
{Name: "json", Desc: `group JSON object with group_config array, e.g. {"group_config":[{"field":"Status","desc":false}]}; use {"group_config":[]} to clear`, Required: true},
|
{Name: "json", Desc: "group JSON object", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
"Supported view types: grid, kanban, gantt.",
|
`Example: --json '{"group_config":[{"field":"fldStatus","desc":false}]}'`,
|
||||||
"Use a JSON object, not a bare array; grouping fields must be supported by the current view.",
|
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
|
||||||
"group_config supports max 3 group items.",
|
|
||||||
"Use +view-get-group first when modifying an existing grouping configuration.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateViewJSONObject(runtime)
|
return validateViewJSONObject(runtime)
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ var BaseViewSetSort = common.Shortcut{
|
|||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
viewRefFlag(true),
|
viewRefFlag(true),
|
||||||
{Name: "json", Desc: `sort_config JSON object, e.g. {"sort_config":[{"field":"Priority","desc":true}]}; use {"sort_config":[]} to clear; max 10 items`, Required: true},
|
{Name: "json", Desc: "sort_config JSON object", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
"Supported view types: grid, kanban, gallery, gantt.",
|
`Example: --json '{"sort_config":[{"field":"fldPriority","desc":true}]}'`,
|
||||||
"Use a JSON object, not a bare array; sorting fields must be supported by the current view.",
|
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
|
||||||
"sort_config supports max 10 sort items.",
|
|
||||||
"Use +view-get-sort first when modifying an existing sort configuration.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateViewJSONObject(runtime)
|
return validateViewJSONObject(runtime)
|
||||||
|
|||||||
@@ -20,12 +20,11 @@ var BaseViewSetTimebar = common.Shortcut{
|
|||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
viewRefFlag(true),
|
viewRefFlag(true),
|
||||||
{Name: "json", Desc: `timebar JSON object with start_time, end_time, title, e.g. {"start_time":"Start Date","end_time":"End Date","title":"Name"}`, Required: true},
|
{Name: "json", Desc: "timebar JSON object", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
"Supported view types: calendar, gantt.",
|
`Example: --json '{"start_time":"fldStart","end_time":"fldEnd","title":"fldTitle"}'`,
|
||||||
"start_time, end_time, and title are required; use date/time fields for start_time and end_time.",
|
"Agent hint: use the lark-base skill's view-set-timebar guide for usage and limits.",
|
||||||
"Use +view-get-timebar first when modifying an existing timebar configuration.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateViewJSONObject(runtime)
|
return validateViewJSONObject(runtime)
|
||||||
|
|||||||
@@ -20,12 +20,11 @@ var BaseViewSetVisibleFields = common.Shortcut{
|
|||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(true),
|
tableRefFlag(true),
|
||||||
viewRefFlag(true),
|
viewRefFlag(true),
|
||||||
{Name: "json", Desc: `visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`, Required: true},
|
{Name: "json", Desc: `visible fields JSON object with "visible_fields"`, Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
"Supported view types: grid, kanban, gallery, calendar, gantt.",
|
`Example: --json '{"visible_fields":["fldXXX"]}'`,
|
||||||
"Use a JSON object, not a bare array; primary field may be forced to the first position by the API.",
|
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
|
||||||
"visible_fields controls both visibility and order; include every field that should remain visible.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateViewJSONObject(runtime)
|
return validateViewJSONObject(runtime)
|
||||||
|
|||||||
@@ -19,15 +19,7 @@ var BaseWorkflowCreate = common.Shortcut{
|
|||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "base-token", Desc: "base token", Required: true},
|
{Name: "base-token", Desc: "base token", Required: true},
|
||||||
{Name: "json", Desc: "workflow body JSON; read lark-base-workflow-guide.md and lark-base-workflow-schema.md before constructing steps", Required: true},
|
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"My Workflow","steps":[...]}`, Required: true},
|
||||||
},
|
|
||||||
Tips: []string{
|
|
||||||
"lark-cli base +workflow-create --base-token <base_token> --json @workflow.json",
|
|
||||||
"client_token is required and should be unique per create request.",
|
|
||||||
"New workflows are created disabled; call +workflow-enable after creation when the user wants it active.",
|
|
||||||
"Before constructing steps, use +table-list and +field-list to confirm real table and field names.",
|
|
||||||
"Step ids must be unique, and every next/children link must reference an existing step id.",
|
|
||||||
"Use lark-base-workflow-guide.md as the entry guide and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ var BaseWorkflowDisable = common.Shortcut{
|
|||||||
{Name: "base-token", Desc: "base token", Required: true},
|
{Name: "base-token", Desc: "base token", Required: true},
|
||||||
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"workflow-id must start with wkf; do not pass a tbl table ID from the same URL.",
|
|
||||||
"Disable only changes workflow state; it does not delete the workflow or its steps.",
|
|
||||||
},
|
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return common.FlagErrorf("--base-token must not be blank")
|
||||||
|
|||||||
@@ -21,11 +21,6 @@ var BaseWorkflowEnable = common.Shortcut{
|
|||||||
{Name: "base-token", Desc: "base token", Required: true},
|
{Name: "base-token", Desc: "base token", Required: true},
|
||||||
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
|
||||||
"workflow-id must start with wkf; do not pass a tbl table ID from the same URL.",
|
|
||||||
"Enable only changes workflow state; it does not modify steps.",
|
|
||||||
"New workflows are created disabled; enable after creation only when the user wants it active.",
|
|
||||||
},
|
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return common.FlagErrorf("--base-token must not be blank")
|
||||||
|
|||||||
@@ -20,13 +20,7 @@ var BaseWorkflowGet = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "base-token", Desc: "base token", Required: true},
|
{Name: "base-token", Desc: "base token", Required: true},
|
||||||
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
||||||
{Name: "user-id-type", Desc: "user ID type for creator/updater fields, default open_id", Enum: []string{"open_id", "union_id", "user_id"}},
|
{Name: "user-id-type", Desc: "user ID type for creator/updater fields", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||||
},
|
|
||||||
Tips: []string{
|
|
||||||
"workflow-id must start with wkf; use +workflow-list if the ID is unknown.",
|
|
||||||
"steps may be an empty array; that is valid for an unconfigured workflow.",
|
|
||||||
"Use +workflow-get before +workflow-update, then edit the returned definition and keep fields you do not intend to change.",
|
|
||||||
"Read lark-base-workflow-schema.md when interpreting or reusing returned steps.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
|
|||||||
@@ -20,11 +20,7 @@ var BaseWorkflowList = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "base-token", Desc: "base token", Required: true},
|
{Name: "base-token", Desc: "base token", Required: true},
|
||||||
{Name: "status", Desc: "filter by status", Enum: []string{"enabled", "disabled"}},
|
{Name: "status", Desc: "filter by status", Enum: []string{"enabled", "disabled"}},
|
||||||
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request, max 100"},
|
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"},
|
||||||
},
|
|
||||||
Tips: []string{
|
|
||||||
"Returns workflow_id values with wkf prefix; pass those IDs to +workflow-get/enable/disable/update.",
|
|
||||||
"This shortcut auto-paginates and returns all matched workflows.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
|
|||||||
@@ -20,16 +20,7 @@ var BaseWorkflowUpdate = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "base-token", Desc: "base token", Required: true},
|
{Name: "base-token", Desc: "base token", Required: true},
|
||||||
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
||||||
{Name: "json", Desc: "workflow body JSON; read lark-base-workflow-guide.md and lark-base-workflow-schema.md before replacing steps", Required: true},
|
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"New Title","steps":[...]}`, Required: true},
|
||||||
},
|
|
||||||
Tips: []string{
|
|
||||||
"lark-cli base +workflow-update --base-token <base_token> --workflow-id <workflow_id> --json @workflow.json",
|
|
||||||
"PUT uses full replacement semantics; omitting steps clears the existing workflow steps.",
|
|
||||||
"Use +workflow-get first, then edit the returned definition and keep title/status/steps fields you do not intend to change.",
|
|
||||||
"workflow-id must start with wkf; do not pass a tbl table ID.",
|
|
||||||
"Step ids must be unique, and every next/children link must reference an existing step id.",
|
|
||||||
"Updating does not enable or disable a workflow; call +workflow-enable or +workflow-disable separately.",
|
|
||||||
"Use lark-base-workflow-guide.md as the entry guide and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.",
|
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/i18n"
|
"github.com/larksuite/cli/internal/i18n"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RuntimeContext provides helpers for shortcut execution.
|
// RuntimeContext provides helpers for shortcut execution.
|
||||||
@@ -71,6 +72,16 @@ func (ctx *RuntimeContext) IsBot() bool {
|
|||||||
return ctx.As().IsBot()
|
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.
|
// UserOpenId returns the current user's open_id from config.
|
||||||
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
|
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
|
||||||
|
|
||||||
@@ -199,6 +210,18 @@ func (ctx *RuntimeContext) Int(name string) int {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Int64 returns an int64 flag value.
|
||||||
|
func (ctx *RuntimeContext) Int64(name string) int64 {
|
||||||
|
v, _ := ctx.Cmd.Flags().GetInt64(name)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Float64 returns a float64 flag value (non-integer numbers).
|
||||||
|
func (ctx *RuntimeContext) Float64(name string) float64 {
|
||||||
|
v, _ := ctx.Cmd.Flags().GetFloat64(name)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
|
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
|
||||||
func (ctx *RuntimeContext) StrArray(name string) []string {
|
func (ctx *RuntimeContext) StrArray(name string) []string {
|
||||||
v, _ := ctx.Cmd.Flags().GetStringArray(name)
|
v, _ := ctx.Cmd.Flags().GetStringArray(name)
|
||||||
@@ -748,6 +771,22 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
|||||||
return runShortcut(cmd, f, &shortcut, botOnly)
|
return runShortcut(cmd, f, &shortcut, botOnly)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if shortcut.PrintFlagSchema != nil {
|
||||||
|
// --print-schema is pure local introspection; relax cobra's
|
||||||
|
// required-flag gate so callers don't need to fill in unrelated
|
||||||
|
// flags just to ask for a schema. ValidateRequiredFlags runs
|
||||||
|
// after PreRunE in cobra, so clearing the annotation here is the
|
||||||
|
// supported way to opt out.
|
||||||
|
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
|
||||||
|
if want, _ := c.Flags().GetBool("print-schema"); !want {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.Flags().VisitAll(func(fl *pflag.Flag) {
|
||||||
|
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
|
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
|
||||||
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
|
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
|
||||||
cmdutil.SetTips(cmd, shortcut.Tips)
|
cmdutil.SetTips(cmd, shortcut.Tips)
|
||||||
@@ -761,6 +800,24 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
|||||||
// runShortcut is the execution pipeline for a declarative shortcut.
|
// runShortcut is the execution pipeline for a declarative shortcut.
|
||||||
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
|
// 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 {
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintln(f.IOStreams.Out, string(out))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
as, err := resolveShortcutIdentity(cmd, f, s)
|
as, err := resolveShortcutIdentity(cmd, f, s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -860,11 +917,23 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
|||||||
}
|
}
|
||||||
rctx.larkSDK = sdk
|
rctx.larkSDK = sdk
|
||||||
|
|
||||||
rctx.Format = rctx.Str("format")
|
if s.HasFormat {
|
||||||
|
rctx.Format = rctx.Str("format")
|
||||||
|
}
|
||||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||||
return rctx, nil
|
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.
|
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
||||||
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
||||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||||
@@ -894,7 +963,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
|
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
|
||||||
}
|
}
|
||||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,7 +988,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return FlagErrorf("--%s: %v", fl.Name, err)
|
return FlagErrorf("--%s: %v", fl.Name, err)
|
||||||
}
|
}
|
||||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1002,6 +1075,14 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
|||||||
var d int
|
var d int
|
||||||
fmt.Sscanf(fl.Default, "%d", &d)
|
fmt.Sscanf(fl.Default, "%d", &d)
|
||||||
cmd.Flags().Int(fl.Name, d, desc)
|
cmd.Flags().Int(fl.Name, d, desc)
|
||||||
|
case "int64":
|
||||||
|
var d int64
|
||||||
|
fmt.Sscanf(fl.Default, "%d", &d)
|
||||||
|
cmd.Flags().Int64(fl.Name, d, desc)
|
||||||
|
case "float64":
|
||||||
|
var d float64
|
||||||
|
fmt.Sscanf(fl.Default, "%g", &d)
|
||||||
|
cmd.Flags().Float64(fl.Name, d, desc)
|
||||||
case "string_array":
|
case "string_array":
|
||||||
cmd.Flags().StringArray(fl.Name, nil, desc)
|
cmd.Flags().StringArray(fl.Name, nil, desc)
|
||||||
case "string_slice":
|
case "string_slice":
|
||||||
@@ -1024,15 +1105,21 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().Bool("dry-run", false, "print request without executing")
|
cmd.Flags().Bool("dry-run", false, "print request without executing")
|
||||||
if cmd.Flags().Lookup("format") == nil {
|
if s.HasFormat {
|
||||||
cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv")
|
cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv")
|
||||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
|
||||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if s.Risk == "high-risk-write" {
|
if s.Risk == "high-risk-write" {
|
||||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||||
}
|
}
|
||||||
|
if s.PrintFlagSchema != nil {
|
||||||
|
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
|
||||||
|
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")
|
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||||
|
if s.HasFormat {
|
||||||
|
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user