mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
Compare commits
23 Commits
feat/lark-
...
feat/lazy-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0efde2f901 | ||
|
|
5ac35fd9fd | ||
|
|
1decb4399d | ||
|
|
d05fbcf041 | ||
|
|
cf47d9b5f9 | ||
|
|
2132472b87 | ||
|
|
27c6333620 | ||
|
|
6bd21ac8c9 | ||
|
|
e4309bb5b2 | ||
|
|
d6a0aadbe4 | ||
|
|
da198cf06a | ||
|
|
ad7b20935c | ||
|
|
6e18185eaa | ||
|
|
aec1bd4b0c | ||
|
|
fe1b6b7bbb | ||
|
|
4c24c6eb94 | ||
|
|
bc1cd72074 | ||
|
|
d2aa27dac8 | ||
|
|
e57d97f341 | ||
|
|
3bca545796 | ||
|
|
57ba4fae61 | ||
|
|
925ae5ecd6 | ||
|
|
4710a294f5 |
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// 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)
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
httpClient := util.NewHTTPClient(0)
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
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/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// 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
|
||||
// the real egress path (and are blocked when proxy plugin fails closed).
|
||||
httpClient := util.NewHTTPClient(0)
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
mcpURL := ep.MCP + "/mcp"
|
||||
|
||||
type probeResult struct {
|
||||
|
||||
61
cmd/notice_test.go
Normal file
61
cmd/notice_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
)
|
||||
|
||||
// composePendingNotice must surface a deprecated-command alias under the
|
||||
// "deprecated_command" key, with the migration target and a skill-update hint,
|
||||
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
|
||||
// without ever reading --help.
|
||||
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+read",
|
||||
Replacement: "+cells-get",
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
|
||||
got := composePendingNotice()
|
||||
if got == nil {
|
||||
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
|
||||
}
|
||||
entry, ok := got["deprecated_command"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing deprecated_command key: %#v", got)
|
||||
}
|
||||
if entry["command"] != "+read" {
|
||||
t.Errorf("command = %v, want +read", entry["command"])
|
||||
}
|
||||
if entry["replacement"] != "+cells-get" {
|
||||
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
|
||||
}
|
||||
if entry["skill"] != "lark-sheets" {
|
||||
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
|
||||
}
|
||||
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
|
||||
t.Errorf("message missing skill-update hint: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing pending, the provider returns nil so no "_notice" field is
|
||||
// emitted on a clean run.
|
||||
func TestComposePendingNoticeEmpty(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
if got := composePendingNotice(); got != nil {
|
||||
// update/skills pending are process-global; only assert the absence of
|
||||
// our own key to stay robust against unrelated pending state.
|
||||
if _, ok := got["deprecated_command"]; ok {
|
||||
t.Fatalf("deprecated_command present after clear: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
198
cmd/root.go
198
cmd/root.go
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
@@ -85,7 +86,15 @@ COMMUNITY:
|
||||
More help: lark-cli <command> --help`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
||||
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
|
||||
// from here. It stays nil in unit tests that invoke a RunE directly with
|
||||
// explicit args — correct, since those don't exercise the whitelist path.
|
||||
var rawInvocationArgs []string
|
||||
|
||||
func Execute() int {
|
||||
rawInvocationArgs = os.Args[1:]
|
||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
@@ -149,29 +158,49 @@ func setupNotices() {
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
output.PendingNotice = composePendingNotice
|
||||
}
|
||||
|
||||
// composePendingNotice merges all process-level pending notices (available
|
||||
// update, skills/binary drift, deprecated-command alias) into the map surfaced
|
||||
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
|
||||
// Extracted from Execute so the composition is unit-testable.
|
||||
func composePendingNotice() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if dep := deprecation.GetPending(); dep != nil {
|
||||
entry := map[string]interface{}{
|
||||
"command": dep.Command,
|
||||
"message": dep.Message(),
|
||||
"action": "lark-cli update",
|
||||
}
|
||||
if dep.Replacement != "" {
|
||||
entry["replacement"] = dep.Replacement
|
||||
}
|
||||
if dep.Skill != "" {
|
||||
entry["skill"] = dep.Skill
|
||||
}
|
||||
notice["deprecated_command"] = entry
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
|
||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||
@@ -269,6 +298,19 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return exitErr.Code
|
||||
}
|
||||
|
||||
// A backward-compat alias records its deprecation notice in PreRunE, which
|
||||
// runs before cobra's required-flag validation — but a missing required flag
|
||||
// fails before RunE and lands here, where the bare "Error:" line would drop
|
||||
// the notice. When a deprecation is pending, route through the structured
|
||||
// envelope so the migration hint still reaches the caller; all other errors
|
||||
// keep the existing plain output.
|
||||
if deprecation.GetPending() != nil {
|
||||
output.WriteErrorEnvelope(errOut, &output.ExitError{
|
||||
Code: 1,
|
||||
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
|
||||
}, string(f.ResolvedIdentity))
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(errOut, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
@@ -335,35 +377,118 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
// they have moved to the typed surface.
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
// A bare group (e.g. `sheets`) legitimately prints help. But an unknown
|
||||
// flag placed before any subcommand (`sheets --badflag`) is whitelisted
|
||||
// away by installUnknownSubcommandGuard, which also leaves args empty —
|
||||
// without this check it would silently fall through to help + exit 0.
|
||||
// Recover the swallowed flag tokens and fail structured so agents (and
|
||||
// the flagDidYouMean contract) still see a real error.
|
||||
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
|
||||
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
"unknown_flags": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return cmd.Help()
|
||||
}
|
||||
unknown := args[0]
|
||||
available := availableSubcommandNames(cmd)
|
||||
suggestions := suggest.Closest(unknown, available, 6)
|
||||
available, deprecated := availableSubcommandNames(cmd)
|
||||
// Rank suggestions across both current and deprecated names so a mistyped
|
||||
// legacy command (e.g. +raed → +read) still resolves; the alias stays
|
||||
// runnable and self-flags via the _notice on execution.
|
||||
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
|
||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
|
||||
strings.Join(suggestions, ", "), cmd.CommandPath())
|
||||
}
|
||||
detail := map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
}
|
||||
// Only services with backward-compat aliases (currently sheets) carry a
|
||||
// deprecated bucket; omit the key elsewhere so every other service's
|
||||
// envelope is unchanged.
|
||||
if len(deprecated) > 0 {
|
||||
detail["deprecated"] = deprecated
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
},
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
subs := make([]string, 0, len(cmd.Commands()))
|
||||
// unknownFlagTokens returns the -/-- tokens in rawArgs that cmd does not define.
|
||||
// installUnknownSubcommandGuard whitelists unknown flags on pure groups so a
|
||||
// mistyped subcommand still reaches the suggestion path; the side effect is that
|
||||
// a lone unknown flag (no subcommand) is swallowed, leaving the group to fall
|
||||
// through to help. This recovers those tokens so the caller can fail structured.
|
||||
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var unknown []string
|
||||
for _, a := range rawArgs {
|
||||
if a == "--" {
|
||||
break // everything after -- is positional
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
continue
|
||||
}
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name != "" && !flagDefinedInTree(cmd, name) {
|
||||
unknown = append(unknown, a)
|
||||
}
|
||||
}
|
||||
return unknown
|
||||
}
|
||||
|
||||
// flagDefinedInTree reports whether name is defined on cmd, its inherited
|
||||
// (persistent) flags, or any direct subcommand. The subcommand case covers a
|
||||
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
|
||||
// --format is injected on every leaf shortcut, not on the group — so only a
|
||||
// genuinely unknown flag like `sheets --badflag` is reported.
|
||||
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
known := func(c *cobra.Command, inherited bool) bool {
|
||||
fs := c.Flags()
|
||||
if inherited {
|
||||
fs = c.InheritedFlags()
|
||||
}
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
if known(cmd, false) || known(cmd, true) {
|
||||
return true
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
if known(c, false) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// availableSubcommandNames returns the invokable subcommand names of cmd, split
|
||||
// into current commands and backward-compatibility aliases (those tagged into
|
||||
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
|
||||
// sorted; hidden commands plus help/completion are omitted.
|
||||
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || !c.IsAvailableCommand() {
|
||||
continue
|
||||
@@ -372,10 +497,15 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
if name == "help" || name == "completion" {
|
||||
continue
|
||||
}
|
||||
subs = append(subs, name)
|
||||
if cmdutil.IsDeprecatedCommand(c) {
|
||||
deprecated = append(deprecated, name)
|
||||
} else {
|
||||
available = append(available, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(subs)
|
||||
return subs
|
||||
sort.Strings(available)
|
||||
sort.Strings(deprecated)
|
||||
return available, deprecated
|
||||
}
|
||||
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
@@ -268,6 +269,54 @@ func (f *failingWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
|
||||
// backward-compat alias that fails on a cobra-level required flag (which
|
||||
// short-circuits before RunE) still routes through the structured envelope,
|
||||
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
|
||||
// switches to WriteErrorEnvelope when a deprecation is pending — so the
|
||||
// migration notice is no longer dropped on the plain "Error:" line.
|
||||
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
||||
})
|
||||
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
|
||||
// nor an *output.ExitError, so it reaches the legacy fallback.
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
|
||||
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
|
||||
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
|
||||
// fix does not reshape every unrecognized cobra error.
|
||||
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
if !strings.HasPrefix(errOut.String(), "Error:") {
|
||||
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
||||
// stderr write fails mid-envelope, handleRootError still returns the typed
|
||||
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -72,6 +73,60 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFlagTokens(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
|
||||
// the subcommand) is distinguished from a genuinely unknown one.
|
||||
for _, c := range drive.Commands() {
|
||||
if c.Name() == "+search" {
|
||||
c.Flags().String("query", "", "")
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
rawArgs []string
|
||||
want []string
|
||||
}{
|
||||
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
|
||||
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
|
||||
{"no flags at all", []string{"drive"}, nil},
|
||||
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
|
||||
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := unknownFlagTokens(drive, tc.rawArgs)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
|
||||
// --badflag, so RunE sees no args; the guard must recover it from
|
||||
// rawInvocationArgs and fail structured rather than print help + exit 0.
|
||||
rawInvocationArgs = []string{"drive", "--badflag"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
err := drive.RunE(drive, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
@@ -164,7 +219,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
got := availableSubcommandNames(root)
|
||||
got, _ := availableSubcommandNames(root)
|
||||
want := []string{"alpha", "gamma"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
@@ -175,3 +230,61 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
root.AddCommand(
|
||||
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
available, deprecated := availableSubcommandNames(root)
|
||||
if len(available) != 1 || available[0] != "+new-cmd" {
|
||||
t.Errorf("available = %v, want [+new-cmd]", available)
|
||||
}
|
||||
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
|
||||
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
|
||||
}
|
||||
}
|
||||
|
||||
// unknownSubcommandRunE must split current vs deprecated subcommands into
|
||||
// separate detail buckets, while suggestions still rank across both so a
|
||||
// mistyped legacy alias resolves.
|
||||
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
|
||||
svc := &cobra.Command{Use: "sheets"}
|
||||
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
svc.AddCommand(
|
||||
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
err := unknownSubcommandRunE(svc, []string{"+reat"})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
|
||||
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
|
||||
t.Errorf("available = %v, want [+cells-get]", available)
|
||||
}
|
||||
deprecated, ok := detail["deprecated"].([]string)
|
||||
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
|
||||
t.Errorf("deprecated = %v, want [+read]", deprecated)
|
||||
}
|
||||
// suggestions rank across both buckets: "+reat" is closest to +read.
|
||||
suggestions, _ := detail["suggestions"].([]string)
|
||||
found := false
|
||||
for _, s := range suggestions {
|
||||
if s == "+read" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
||||
@@ -28,7 +28,7 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return util.FallbackTransport()
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
_ "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) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
|
||||
var transport http.RoundTripper = util.SharedTransport()
|
||||
transport = &RetryTransport{Base: transport}
|
||||
transport = &SecurityHeaderTransport{Base: transport}
|
||||
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
|
||||
transport = wrapWithExtension(transport)
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = &RetryTransport{Base: rt}
|
||||
rt = &SecurityHeaderTransport{Base: rt}
|
||||
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
|
||||
rt = wrapWithExtension(rt)
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Transport: rt,
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
@@ -141,7 +141,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
}
|
||||
|
||||
func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = util.SharedTransport()
|
||||
var sdkTransport http.RoundTripper = transport.Shared()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
|
||||
18
internal/cmdutil/groups.go
Normal file
18
internal/cmdutil/groups.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
|
||||
// command — one kept alive for users whose skill predates a refactor. Service
|
||||
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
|
||||
// rendering and unknown-subcommand suggestions read it to separate these
|
||||
// aliases from the current commands.
|
||||
const DeprecatedGroupID = "deprecated"
|
||||
|
||||
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
|
||||
func IsDeprecatedCommand(c *cobra.Command) bool {
|
||||
return c != nil && c.GroupID == DeprecatedGroupID
|
||||
}
|
||||
@@ -75,8 +75,6 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
h.Set("x-tt-env", "ppe_moa_canvas")
|
||||
h.Set("x-use-ppe", "1")
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// RetryTransport is an http.RoundTripper that retries on 5xx responses
|
||||
@@ -24,7 +24,7 @@ func (t *RetryTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return util.FallbackTransport()
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *RetryTransport) delay() time.Duration {
|
||||
@@ -69,7 +69,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
if t.Base != nil {
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
return util.FallbackTransport().RoundTrip(req)
|
||||
return transport.Fallback().RoundTrip(req)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
return util.FallbackTransport().RoundTrip(req)
|
||||
return transport.Fallback().RoundTrip(req)
|
||||
}
|
||||
|
||||
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
|
||||
@@ -100,7 +100,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return util.FallbackTransport()
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
|
||||
@@ -332,7 +332,7 @@ func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
|
||||
|
||||
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
|
||||
// the transport still sets X-Cli-Build and routes the request through
|
||||
// util.FallbackTransport rather than panicking. This covers the fallback
|
||||
// transport.Fallback rather than panicking. This covers the fallback
|
||||
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
|
||||
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
|
||||
var receivedBuild string
|
||||
|
||||
57
internal/deprecation/deprecation.go
Normal file
57
internal/deprecation/deprecation.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package deprecation carries a process-level notice that the command currently
|
||||
// being executed is a backward-compatibility alias, kept alive for users whose
|
||||
// skill predates a refactor. The notice is surfaced in JSON output envelopes via
|
||||
// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck.
|
||||
//
|
||||
// A CLI process runs exactly one shortcut, so a single process-level slot is
|
||||
// sufficient: the command's Execute records the notice before producing output,
|
||||
// and the output layer reads it back when building the envelope.
|
||||
package deprecation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Notice describes a deprecated command alias and the current command that
|
||||
// replaces it. Replacement and Skill are optional.
|
||||
type Notice struct {
|
||||
Command string `json:"command"`
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
Skill string `json:"skill,omitempty"`
|
||||
}
|
||||
|
||||
// Message returns a single-line, AI-agent-parseable description of the alias
|
||||
// plus the canonical fix (update the skill). Mirrors the style of
|
||||
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
|
||||
func (n *Notice) Message() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(n.Command)
|
||||
b.WriteString(" is a pre-refactor compatibility alias")
|
||||
if n.Replacement != "" {
|
||||
b.WriteString("; use ")
|
||||
b.WriteString(n.Replacement)
|
||||
b.WriteString(" instead")
|
||||
}
|
||||
if n.Skill != "" {
|
||||
b.WriteString("; update your ")
|
||||
b.WriteString(n.Skill)
|
||||
b.WriteString(" skill, run: lark-cli update")
|
||||
} else {
|
||||
b.WriteString("; update your skill, run: lark-cli update")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// pending stores the latest deprecation notice for the current process.
|
||||
var pending atomic.Pointer[Notice]
|
||||
|
||||
// SetPending stores the notice for consumption by output decorators.
|
||||
// Pass nil to clear.
|
||||
func SetPending(n *Notice) { pending.Store(n) }
|
||||
|
||||
// GetPending returns the pending deprecation notice, or nil.
|
||||
func GetPending() *Notice { return pending.Load() }
|
||||
58
internal/deprecation/deprecation_test.go
Normal file
58
internal/deprecation/deprecation_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package deprecation
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNoticeMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
notice Notice
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "replacement and skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no replacement",
|
||||
notice: Notice{Command: "+read", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.notice.Message(); got != tt.want {
|
||||
t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetPending(t *testing.T) {
|
||||
t.Cleanup(func() { SetPending(nil) })
|
||||
|
||||
SetPending(nil)
|
||||
if got := GetPending(); got != nil {
|
||||
t.Fatalf("expected nil pending after clear, got %#v", got)
|
||||
}
|
||||
|
||||
n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"}
|
||||
SetPending(n)
|
||||
got := GetPending()
|
||||
if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" {
|
||||
t.Fatalf("GetPending() = %#v, want %#v", got, n)
|
||||
}
|
||||
|
||||
SetPending(nil)
|
||||
if GetPending() != nil {
|
||||
t.Fatal("expected nil after clearing")
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"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) {
|
||||
// Route through the shared proxy-plugin-aware transport so remote API
|
||||
// definition fetches honor proxy plugin mode instead of bypassing it.
|
||||
client := util.NewHTTPClient(fetchTimeout)
|
||||
client := transport.NewHTTPClient(fetchTimeout)
|
||||
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package proxyplugin implements the ~/.lark-cli/proxy_config.json based security proxy plugin mode.
|
||||
// Package transport owns how the CLI assembles its outbound HTTP transport: the
|
||||
// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY
|
||||
// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode.
|
||||
//
|
||||
// It supports:
|
||||
// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy
|
||||
// - trusting an additional root CA PEM bundle for MITM/inspection proxies
|
||||
//
|
||||
// Environment variables override matching values from proxy_config.json.
|
||||
package proxyplugin
|
||||
// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback
|
||||
// proxy, optionally trusting an extra root CA PEM bundle for TLS-inspection
|
||||
// proxies, and fails closed on misconfiguration. Environment variables override
|
||||
// matching values from proxy_config.json.
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -222,21 +223,6 @@ func (c *Config) proxyURL() (*url.URL, error) {
|
||||
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.
|
||||
// Caller owns the returned *http.Transport.
|
||||
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package proxyplugin
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
83
internal/transport/shared.go
Normal file
83
internal/transport/shared.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// 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
|
||||
})
|
||||
156
internal/transport/shared_test.go
Normal file
156
internal/transport/shared_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// 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,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package proxyplugin
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package proxyplugin
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package proxyplugin
|
||||
package transport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -16,7 +16,7 @@ var proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport)
|
||||
|
||||
// cachedBlockedTransport is a fail-closed transport cached on first use when
|
||||
// the proxy plugin config exists but is invalid. This avoids cloning
|
||||
// http.DefaultTransport on every SharedTransport call.
|
||||
// http.DefaultTransport on every pluginTransport call.
|
||||
var cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
|
||||
|
||||
func buildBlockedTransport() http.RoundTripper {
|
||||
@@ -28,7 +28,7 @@ func buildProxyPluginTransport() http.RoundTripper {
|
||||
if !ok {
|
||||
// Cannot clone the stdlib transport. Fail closed with a concrete
|
||||
// *http.Transport (not a bare RoundTripper) so downcasting callers such
|
||||
// as util.FallbackTransport cannot silently degrade this into a
|
||||
// as Fallback cannot silently degrade this into a
|
||||
// direct-egress transport.
|
||||
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
|
||||
}
|
||||
|
||||
// SharedTransport returns the proxy plugin transport when proxy plugin mode is
|
||||
// pluginTransport 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.
|
||||
func SharedTransport() (http.RoundTripper, bool) {
|
||||
func pluginTransport() (http.RoundTripper, bool) {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
return cachedBlockedTransport(), true
|
||||
@@ -68,7 +68,7 @@ func SharedTransport() (http.RoundTripper, bool) {
|
||||
// err. It clones http.DefaultTransport when possible (preserving dial/timeout
|
||||
// tuning); otherwise it builds a minimal transport. Returning a concrete
|
||||
// *http.Transport (rather than a bare RoundTripper) is required so downcasting
|
||||
// callers such as util.FallbackTransport cannot silently degrade a fail-closed
|
||||
// callers such as Fallback cannot silently degrade a fail-closed
|
||||
// signal into a direct-egress transport.
|
||||
func failClosedTransport(err error) *http.Transport {
|
||||
if def, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package proxyplugin
|
||||
package transport
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -20,21 +20,21 @@ func resetProxyPluginState() {
|
||||
cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
|
||||
}
|
||||
|
||||
func TestSharedTransport_NotConfigured(t *testing.T) {
|
||||
func TestPluginTransport_NotConfigured(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
|
||||
tr, ok := SharedTransport()
|
||||
tr, ok := pluginTransport()
|
||||
if ok {
|
||||
t.Fatalf("SharedTransport() ok = true, want false")
|
||||
t.Fatalf("pluginTransport() ok = true, want false")
|
||||
}
|
||||
if tr != nil {
|
||||
t.Fatalf("SharedTransport() transport = %T, want nil", tr)
|
||||
t.Fatalf("pluginTransport() transport = %T, want nil", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) {
|
||||
func TestPluginTransport_EnabledReturnsFixedProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
@@ -46,13 +46,13 @@ func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) {
|
||||
"LARKSUITE_CLI_CA_PATH": ""
|
||||
}`), 0600)
|
||||
|
||||
rt, ok := SharedTransport()
|
||||
rt, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("SharedTransport() ok = false, want true")
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
tr, ok := rt.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("SharedTransport() = %T, want *http.Transport", rt)
|
||||
t.Fatalf("pluginTransport() = %T, want *http.Transport", rt)
|
||||
}
|
||||
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err != nil {
|
||||
@@ -63,7 +63,7 @@ func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) {
|
||||
func TestPluginTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
@@ -72,12 +72,12 @@ func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test
|
||||
|
||||
writeFile(t, Path(), []byte(`{`), 0600)
|
||||
|
||||
rt, ok := SharedTransport()
|
||||
rt, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("SharedTransport() ok = false, want true")
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
if rt == http.DefaultTransport {
|
||||
t.Fatalf("SharedTransport() returned http.DefaultTransport, want fail-closed transport")
|
||||
t.Fatalf("pluginTransport() returned http.DefaultTransport, want fail-closed transport")
|
||||
}
|
||||
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err == nil {
|
||||
@@ -88,23 +88,23 @@ func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_InvalidConfigReturnsCachedInstance(t *testing.T) {
|
||||
func TestPluginTransport_InvalidConfigReturnsCachedInstance(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
|
||||
writeFile(t, Path(), []byte(`{`), 0600)
|
||||
|
||||
a, ok := SharedTransport()
|
||||
a, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("SharedTransport() ok = false, want true")
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
b, ok := SharedTransport()
|
||||
b, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("SharedTransport() ok = false, want true")
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("SharedTransport() returned different instances on repeated calls; blocked transport must be cached")
|
||||
t.Fatalf("pluginTransport() returned different instances on repeated calls; blocked transport must be cached")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,13 +148,13 @@ func TestBuildProxyPluginTransport_NonTransportDefaultFailsClosed(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_InvalidConfigBlockerIsConcreteTransport guards the
|
||||
// fail-closed invariant that util.FallbackTransport relies on: even when
|
||||
// TestPluginTransport_InvalidConfigBlockerIsConcreteTransport guards the
|
||||
// fail-closed invariant that Fallback relies on: even when
|
||||
// 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
|
||||
// were a bare RoundTripper, util.FallbackTransport would downcast-fail and
|
||||
// were a bare RoundTripper, Fallback would downcast-fail and
|
||||
// silently degrade it into a direct-egress transport.
|
||||
func TestSharedTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
|
||||
func TestPluginTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
@@ -163,12 +163,12 @@ func TestSharedTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
|
||||
|
||||
writeFile(t, Path(), []byte(`{`), 0600)
|
||||
|
||||
rt, ok := SharedTransport()
|
||||
rt, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("SharedTransport() ok = false, want true")
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
if _, isTransport := rt.(*http.Transport); !isTransport {
|
||||
t.Fatalf("SharedTransport() blocked transport = %T, want *http.Transport so FallbackTransport cannot degrade it to direct egress", rt)
|
||||
t.Fatalf("pluginTransport() blocked transport = %T, want *http.Transport so Fallback cannot degrade it to direct egress", rt)
|
||||
}
|
||||
// Must remain fail-closed.
|
||||
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
104
internal/transport/warn.go
Normal file
104
internal/transport/warn.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
@@ -1,44 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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.
|
||||
func TestDetectProxyEnv(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
@@ -61,94 +34,17 @@ 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.
|
||||
func TestWarnIfProxied_WithProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
// Reset the once guard for this test
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
out := buf.String()
|
||||
if out == "" {
|
||||
@@ -166,6 +62,7 @@ func TestWarnIfProxied_WithProxy(t *testing.T) {
|
||||
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
for _, k := range proxyEnvKeys {
|
||||
@@ -173,41 +70,25 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no output when no proxy is set, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnIfProxied_SilentWhenNonInteractive(t *testing.T) {
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
// Non-interactive (interactive=false) mirrors agent / CI / piped invocations
|
||||
// where stdin is not a TTY. The proxy warning must be suppressed so callers
|
||||
// that parse stdout as JSON — often merging streams with `2>&1` — are not
|
||||
// corrupted by a stray stderr line.
|
||||
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, false)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no warning in non-interactive mode, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_SilentWhenDisabled verifies that EnvNoProxy suppresses warnings.
|
||||
// TestWarnIfProxied_SilentWhenDisabled verifies that LARK_CLI_NO_PROXY suppresses warnings.
|
||||
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String())
|
||||
@@ -218,15 +99,16 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTP_PROXY", "http://proxy:1234")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
first := buf.String()
|
||||
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
second := buf.String()
|
||||
|
||||
if first == "" {
|
||||
@@ -255,7 +137,7 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "127.0.0.1:3128") {
|
||||
@@ -274,7 +156,7 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_ProxyPluginCustomCAWarns verifies that when a custom CA is
|
||||
// trusted, the warning surfaces the TLS-interception capability (V3).
|
||||
// trusted, the warning surfaces the TLS-interception capability.
|
||||
func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
@@ -287,7 +169,7 @@ func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
|
||||
t.Cleanup(func() { proxyPluginStatus = old })
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "custom CA") {
|
||||
@@ -301,25 +183,6 @@ 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
|
||||
// warning never leaks credentials embedded in the configured proxy address.
|
||||
func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
||||
@@ -332,7 +195,7 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
||||
t.Cleanup(func() { proxyPluginStatus = old })
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, "s3cret") {
|
||||
@@ -348,8 +211,6 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
||||
|
||||
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
|
||||
func TestRedactProxyURL(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
@@ -376,12 +237,13 @@ func TestRedactProxyURL(t *testing.T) {
|
||||
func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
out := buf.String()
|
||||
if bytes.Contains([]byte(out), []byte("s3cret")) {
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -64,7 +64,7 @@ func httpClient() *http.Client {
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: fetchTimeout,
|
||||
Transport: util.SharedTransport(),
|
||||
Transport: transport.Shared(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@ var BaseAdvpermDisable = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{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 {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseAdvpermEnable = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{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 {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -24,6 +24,11 @@ var BaseBaseCopy = common.Shortcut{
|
||||
{Name: "without-content", Type: "bool", Desc: "copy structure only"},
|
||||
{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,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseCopy(runtime)
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseBaseCreate = common.Shortcut{
|
||||
{Name: "folder-token", Desc: "folder token for destination"},
|
||||
{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,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseCreate(runtime)
|
||||
|
||||
@@ -20,7 +20,12 @@ var BaseDataQuery = common.Shortcut{
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "dsl", Desc: "query JSON DSL (LiteQuery Protocol)", Required: 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},
|
||||
},
|
||||
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 {
|
||||
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") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lark-base skill") {
|
||||
if !strings.Contains(err.Error(), "match the documented shape") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "array") {
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseFormCreate = common.Shortcut{
|
||||
{Name: "name", Desc: "form name", Required: true},
|
||||
{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 {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms").
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseFormDelete = common.Shortcut{
|
||||
{Name: "table-id", Desc: "table 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 {
|
||||
return common.NewDryRunAPI().
|
||||
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{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", 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 {
|
||||
return common.NewDryRunAPI().
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseFormQuestionsDelete = common.Shortcut{
|
||||
{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},
|
||||
},
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseFormQuestionsList = common.Shortcut{
|
||||
{Name: "table-id", Desc: "table 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 {
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
|
||||
@@ -17,7 +17,10 @@ var BaseBaseGet = common.Shortcut{
|
||||
Scopes: []string{"base:app:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true)},
|
||||
DryRun: dryRunBaseGet,
|
||||
Tips: []string{
|
||||
"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 {
|
||||
return executeBaseGet(runtime)
|
||||
},
|
||||
|
||||
@@ -25,7 +25,12 @@ var BaseRoleCreate = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "json", Desc: `body JSON (AdvPermBaseRoleConfig), e.g. {"role_name":"Reviewer","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
|
||||
{Name: "json", Desc: "role config JSON; read lark-base-role-guide.md and role-config.md before constructing permissions", 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 {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -26,6 +26,12 @@ var BaseRoleDelete = common.Shortcut{
|
||||
{Name: "base-token", Desc: "base token", 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 {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -27,6 +27,10 @@ var BaseRoleGet = common.Shortcut{
|
||||
{Name: "base-token", Desc: "base token", 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 {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -26,6 +26,10 @@ var BaseRoleList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{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 {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -26,7 +26,13 @@ var BaseRoleUpdate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
|
||||
{Name: "json", Desc: `body JSON (delta AdvPermBaseRoleConfig), e.g. {"role_name":"New Name","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
|
||||
{Name: "json", Desc: "delta role config JSON; read lark-base-role-guide.md and role-config.md before changing permissions", 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 {
|
||||
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 {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
func formatJSONError(flagName string, target string, err error) error {
|
||||
|
||||
@@ -198,6 +198,25 @@ 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) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldCreate.Mount(parent, &cmdutil.Factory{})
|
||||
@@ -251,20 +270,19 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
wantHelp: []string{
|
||||
"requires keyword/search_fields",
|
||||
"optional select_fields/view_id/offset/limit",
|
||||
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
|
||||
"for keyword search only",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
`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>"]`,
|
||||
"Happy path fields: keyword (string), search_fields",
|
||||
"search_fields length 1-20",
|
||||
"limit range 1-200 defaults to 10",
|
||||
"view_id scopes search to records in that view",
|
||||
"Default output is markdown",
|
||||
"only for keyword search",
|
||||
"lark-base record read SOP",
|
||||
"inventing search JSON",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -311,6 +329,401 @@ 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) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
@@ -328,7 +741,7 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
|
||||
"full field-definition PUT semantics",
|
||||
"Read the current field first with +field-get",
|
||||
|
||||
@@ -22,6 +22,9 @@ var BaseDashboardArrange = common.Shortcut{
|
||||
dashboardIDFlag(true),
|
||||
{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,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeDashboardArrange(runtime)
|
||||
|
||||
@@ -25,10 +25,19 @@ var BaseDashboardBlockCreate = common.Shortcut{
|
||||
dashboardIDFlag(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: "data-config", Desc: "data config JSON object (table_name, series, count_all, group_by, filter, etc.)"},
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
|
||||
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
|
||||
{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 {
|
||||
pc := newParseCtx(runtime)
|
||||
if runtime.Bool("no-validate") {
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseDashboardBlockDelete = common.Shortcut{
|
||||
dashboardIDFlag(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 {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id").
|
||||
|
||||
@@ -24,6 +24,11 @@ var BaseDashboardBlockGet = common.Shortcut{
|
||||
blockIDFlag(true),
|
||||
{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 {
|
||||
params := map[string]interface{}{}
|
||||
if uid := strings.TrimSpace(runtime.Str("user-id-type")); uid != "" {
|
||||
|
||||
@@ -23,6 +23,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
"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.",
|
||||
"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.",
|
||||
|
||||
@@ -21,9 +21,13 @@ var BaseDashboardBlockList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
{Name: "page-size", Desc: "page size (max 100)"},
|
||||
{Name: "page-size", Desc: "page size, default 20, max 100"},
|
||||
{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 {
|
||||
params := map[string]interface{}{}
|
||||
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {
|
||||
|
||||
@@ -24,10 +24,18 @@ var BaseDashboardBlockUpdate = common.Shortcut{
|
||||
dashboardIDFlag(true),
|
||||
blockIDFlag(true),
|
||||
{Name: "name", Desc: "new block name"},
|
||||
{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: open_id / union_id / user_id"},
|
||||
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
|
||||
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
|
||||
{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 {
|
||||
pc := newParseCtx(runtime)
|
||||
if runtime.Bool("no-validate") {
|
||||
|
||||
@@ -20,7 +20,10 @@ var BaseDashboardCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "name", Desc: "dashboard name", Required: true},
|
||||
{Name: "theme-style", Desc: "theme style"},
|
||||
{Name: "theme-style", Desc: "theme style, defaults to platform default when omitted"},
|
||||
},
|
||||
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 {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
@@ -21,6 +21,11 @@ var BaseDashboardDelete = common.Shortcut{
|
||||
baseTokenFlag(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 {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").
|
||||
|
||||
@@ -21,6 +21,9 @@ var BaseDashboardGet = common.Shortcut{
|
||||
baseTokenFlag(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 {
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").
|
||||
|
||||
@@ -20,9 +20,12 @@ var BaseDashboardList = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "page-size", Desc: "page size (max 100)"},
|
||||
{Name: "page-size", Desc: "page size, max 100"},
|
||||
{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 {
|
||||
params := map[string]interface{}{}
|
||||
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {
|
||||
|
||||
@@ -21,7 +21,7 @@ var BaseDashboardUpdate = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
{Name: "name", Desc: "new dashboard name"},
|
||||
{Name: "theme-style", Desc: "theme style"},
|
||||
{Name: "theme-style", Desc: "theme style, leave empty to keep current theme"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
@@ -23,7 +23,8 @@ 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},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Status","type":"text"}'`,
|
||||
`Example text: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --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.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -17,7 +17,11 @@ var BaseFieldDelete = common.Shortcut{
|
||||
Scopes: []string{"base:field:delete"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
|
||||
DryRun: dryRunFieldDelete,
|
||||
Tips: []string{
|
||||
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 {
|
||||
return executeFieldDelete(runtime)
|
||||
},
|
||||
|
||||
@@ -17,7 +17,12 @@ var BaseFieldGet = common.Shortcut{
|
||||
Scopes: []string{"base:field:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
|
||||
DryRun: dryRunFieldGet,
|
||||
Tips: []string{
|
||||
`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 {
|
||||
return executeFieldGet(runtime)
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ var BaseFieldList = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
},
|
||||
DryRun: dryRunFieldList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -22,7 +22,11 @@ var BaseFieldSearchOptions = common.Shortcut{
|
||||
fieldRefFlag(true),
|
||||
{Name: "keyword", Desc: "keyword for option query"},
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size"},
|
||||
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size, default 30"},
|
||||
},
|
||||
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,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -24,8 +24,9 @@ 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},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`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"}]}'`,
|
||||
baseHighRiskYesTip,
|
||||
`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 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.",
|
||||
"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.",
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestParseHelpers(t *testing.T) {
|
||||
if err != nil || obj["name"] != "demo" {
|
||||
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(), "lark-base skill") || 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(), "match the documented shape") || strings.Contains(err.Error(), "array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
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") {
|
||||
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(), "lark-base skill") {
|
||||
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") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
|
||||
@@ -334,11 +334,11 @@ func TestJSONInputHelpers(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
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(), "lark-base skill") {
|
||||
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") {
|
||||
t.Fatalf("syntaxErr=%v", syntaxErr)
|
||||
}
|
||||
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(), "lark-base skill") {
|
||||
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") {
|
||||
t.Fatalf("typeErr=%v", typeErr)
|
||||
}
|
||||
}
|
||||
|
||||
6
shortcuts/base/high_risk.go
Normal file
6
shortcuts/base/high_risk.go
Normal file
@@ -0,0 +1,6 @@
|
||||
// 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,13 +19,14 @@ var BaseRecordBatchCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(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.",
|
||||
{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},
|
||||
},
|
||||
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 {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
|
||||
@@ -19,13 +19,14 @@ var BaseRecordBatchUpdate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(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.",
|
||||
{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},
|
||||
},
|
||||
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 {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseRecordDelete = common.Shortcut{
|
||||
{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"]}`},
|
||||
},
|
||||
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 {
|
||||
return validateRecordSelection(runtime)
|
||||
},
|
||||
|
||||
@@ -21,7 +21,11 @@ var BaseRecordHistoryList = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
{Name: "max-version", Type: "int", Desc: "max version for next page"},
|
||||
{Name: "page-size", Type: "int", Default: "30", Desc: "pagination size"},
|
||||
{Name: "page-size", Type: "int", Default: "30", Desc: "pagination size, max 50"},
|
||||
},
|
||||
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,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -15,6 +15,13 @@ import (
|
||||
const maxRecordSelectionCount = 200
|
||||
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 {
|
||||
recordIDs []string
|
||||
selectFields []string
|
||||
|
||||
@@ -20,17 +20,15 @@ var BaseRecordSearch = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: `record search JSON object; requires keyword/search_fields, optional select_fields/view_id/offset/limit`, Required: 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},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
`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}.`,
|
||||
`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).`,
|
||||
"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.",
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"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.",
|
||||
"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.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
|
||||
@@ -22,8 +22,9 @@ 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},
|
||||
},
|
||||
Tips: []string{
|
||||
`Single record: --base-token xxx --table-id tblxxx --record-ids recxxx`,
|
||||
`Multiple records: --base-token xxx --table-id tblxxx --record-ids rec001,rec002,rec003`,
|
||||
`Example: lark-cli base +record-share-link-create --base-token <base_token> --table-id <table_id> --record-ids <record_id>`,
|
||||
"Max 100 record IDs per call; duplicate IDs are ignored.",
|
||||
"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 {
|
||||
return validateRecordShareBatch(runtime)
|
||||
|
||||
@@ -117,6 +117,7 @@ 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},
|
||||
},
|
||||
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`,
|
||||
`Repeat --file-token to remove multiple attachments from the same cell in one call.`,
|
||||
`This is a high-risk write command and requires --yes.`,
|
||||
|
||||
@@ -20,12 +20,14 @@ var BaseRecordUpsert = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(false),
|
||||
{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.",
|
||||
{Name: "json", Desc: `record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`, Required: true},
|
||||
},
|
||||
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 {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
|
||||
@@ -20,7 +20,11 @@ var BaseTableCreate = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
{Name: "name", Desc: "table name", Required: true},
|
||||
{Name: "view", Desc: "view JSON object/array for create"},
|
||||
{Name: "fields", Desc: "field JSON 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"}]}]`},
|
||||
},
|
||||
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 {
|
||||
return validateTableCreate(runtime)
|
||||
|
||||
@@ -17,7 +17,12 @@ var BaseTableDelete = common.Shortcut{
|
||||
Scopes: []string{"base:table:delete"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)},
|
||||
DryRun: dryRunTableDelete,
|
||||
Tips: []string{
|
||||
`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 {
|
||||
return executeTableDelete(runtime)
|
||||
},
|
||||
|
||||
@@ -17,7 +17,11 @@ var BaseTableGet = common.Shortcut{
|
||||
Scopes: []string{"base:table:read", "base:field:read", "base:view:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)},
|
||||
DryRun: dryRunTableGet,
|
||||
Tips: []string{
|
||||
`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 {
|
||||
return executeTableGet(runtime)
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ var BaseTableList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "50", Desc: "pagination limit"},
|
||||
{Name: "limit", Type: "int", Default: "50", Desc: "pagination size, range 1-100"},
|
||||
},
|
||||
DryRun: dryRunTableList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -19,11 +19,13 @@ var BaseViewCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "view JSON object/array", Required: true},
|
||||
{Name: "json", Desc: "view JSON object/array; type defaults to grid; type range: grid, kanban, gallery, calendar, gantt", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Main","type":"grid"}'`,
|
||||
"Agent hint: use the lark-base skill's view-create guide for usage and limits.",
|
||||
`Example: lark-cli base +view-create --base-token <base_token> --table-id <table_id> --json '{"name":"Main","type":"grid"}'`,
|
||||
`Minimal: --json '{"name":"Main"}' creates a grid view.`,
|
||||
"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 {
|
||||
return validateViewCreate(runtime)
|
||||
|
||||
@@ -17,7 +17,11 @@ var BaseViewDelete = common.Shortcut{
|
||||
Scopes: []string{"base:view:write_only"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)},
|
||||
DryRun: dryRunViewDelete,
|
||||
Tips: []string{
|
||||
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 {
|
||||
return executeViewDelete(runtime)
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ var BaseViewList = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
},
|
||||
DryRun: dryRunViewList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -20,11 +20,12 @@ var BaseViewSetCard = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "card JSON object", Required: true},
|
||||
{Name: "json", Desc: `card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"cover_field":"fldCover"}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-card guide for usage and limits.",
|
||||
"Supported view types: gallery, kanban.",
|
||||
"cover_field should be an attachment field id/name, or null to clear.",
|
||||
"Use +view-get-card first when updating an existing card view configuration.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
|
||||
@@ -20,10 +20,9 @@ var BaseViewSetFilter = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "filter JSON object", Required: true},
|
||||
{Name: "json", Desc: `filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`, Required: true},
|
||||
},
|
||||
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.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -20,11 +20,13 @@ var BaseViewSetGroup = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "group JSON object", Required: 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},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"group_config":[{"field":"fldStatus","desc":false}]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
|
||||
"Supported view types: grid, kanban, gantt.",
|
||||
"Use a JSON object, not a bare array; grouping fields must be supported by the current view.",
|
||||
"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 {
|
||||
return validateViewJSONObject(runtime)
|
||||
|
||||
@@ -20,11 +20,13 @@ var BaseViewSetSort = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "sort_config JSON object", Required: 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},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"sort_config":[{"field":"fldPriority","desc":true}]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
|
||||
"Supported view types: grid, kanban, gallery, gantt.",
|
||||
"Use a JSON object, not a bare array; sorting fields must be supported by the current view.",
|
||||
"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 {
|
||||
return validateViewJSONObject(runtime)
|
||||
|
||||
@@ -20,11 +20,12 @@ var BaseViewSetTimebar = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "timebar JSON object", Required: 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},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"start_time":"fldStart","end_time":"fldEnd","title":"fldTitle"}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-timebar guide for usage and limits.",
|
||||
"Supported view types: calendar, gantt.",
|
||||
"start_time, end_time, and title are required; use date/time fields for start_time and end_time.",
|
||||
"Use +view-get-timebar first when modifying an existing timebar configuration.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
|
||||
@@ -20,11 +20,12 @@ var BaseViewSetVisibleFields = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: `visible fields JSON object with "visible_fields"`, Required: true},
|
||||
{Name: "json", Desc: `visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"visible_fields":["fldXXX"]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
|
||||
"Supported view types: grid, kanban, gallery, calendar, gantt.",
|
||||
"Use a JSON object, not a bare array; primary field may be forced to the first position by the API.",
|
||||
"visible_fields controls both visibility and order; include every field that should remain visible.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
|
||||
@@ -19,7 +19,15 @@ var BaseWorkflowCreate = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"My Workflow","steps":[...]}`, 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},
|
||||
},
|
||||
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 {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -21,6 +21,10 @@ var BaseWorkflowDisable = common.Shortcut{
|
||||
{Name: "base-token", Desc: "base token", 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 {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -21,6 +21,11 @@ var BaseWorkflowEnable = common.Shortcut{
|
||||
{Name: "base-token", Desc: "base token", 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 {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -20,7 +20,13 @@ var BaseWorkflowGet = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
||||
{Name: "user-id-type", Desc: "user ID type for creator/updater fields", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "user-id-type", Desc: "user ID type for creator/updater fields, default open_id", 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 {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -20,7 +20,11 @@ var BaseWorkflowList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{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 {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -20,7 +20,16 @@ var BaseWorkflowUpdate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
||||
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"New Title","steps":[...]}`, 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},
|
||||
},
|
||||
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 {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -210,12 +210,6 @@ func (ctx *RuntimeContext) Int(name string) int {
|
||||
return v
|
||||
}
|
||||
|
||||
// Int64 returns an int64 flag value.
|
||||
func (ctx *RuntimeContext) Int64(name string) int64 {
|
||||
v, _ := ctx.Cmd.Flags().GetInt64(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// Float64 returns a float64 flag value (non-integer numbers).
|
||||
func (ctx *RuntimeContext) Float64(name string) float64 {
|
||||
v, _ := ctx.Cmd.Flags().GetFloat64(name)
|
||||
@@ -771,19 +765,26 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
return runShortcut(cmd, f, &shortcut, botOnly)
|
||||
},
|
||||
}
|
||||
if shortcut.PrintFlagSchema != nil {
|
||||
// --print-schema is pure local introspection; relax cobra's
|
||||
// required-flag gate so callers don't need to fill in unrelated
|
||||
// flags just to ask for a schema. ValidateRequiredFlags runs
|
||||
// after PreRunE in cobra, so clearing the annotation here is the
|
||||
// supported way to opt out.
|
||||
if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil {
|
||||
onInvoke := shortcut.OnInvoke
|
||||
relaxRequiredForSchema := shortcut.PrintFlagSchema != nil
|
||||
// PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses:
|
||||
// - OnInvoke: fire a side effect (e.g. a deprecation notice) that must
|
||||
// surface even when the call later fails on a missing required flag.
|
||||
// - --print-schema: pure local introspection; relax the required-flag
|
||||
// gate so callers don't fill in unrelated flags just to ask for a
|
||||
// schema (clearing the annotation here is the supported opt-out).
|
||||
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
|
||||
if want, _ := c.Flags().GetBool("print-schema"); !want {
|
||||
return nil
|
||||
if onInvoke != nil {
|
||||
onInvoke()
|
||||
}
|
||||
if relaxRequiredForSchema {
|
||||
if want, _ := c.Flags().GetBool("print-schema"); want {
|
||||
c.Flags().VisitAll(func(fl *pflag.Flag) {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
})
|
||||
}
|
||||
}
|
||||
c.Flags().VisitAll(func(fl *pflag.Flag) {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -808,6 +809,13 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
flagName, _ := cmd.Flags().GetString("flag-name")
|
||||
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
|
||||
if err != nil {
|
||||
// PrintFlagSchema implementations return bare errors; wrap as a
|
||||
// structured ExitError so --print-schema (an agent-facing
|
||||
// introspection path) yields a parseable envelope, not a plain
|
||||
// string.
|
||||
if _, ok := err.(*output.ExitError); !ok {
|
||||
err = output.Errorf(output.ExitValidation, "print_schema_error", "%s", err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
@@ -917,9 +925,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
}
|
||||
rctx.larkSDK = sdk
|
||||
|
||||
if s.HasFormat {
|
||||
rctx.Format = rctx.Str("format")
|
||||
}
|
||||
rctx.Format = rctx.Str("format")
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx, nil
|
||||
}
|
||||
@@ -1075,10 +1081,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
var d int
|
||||
fmt.Sscanf(fl.Default, "%d", &d)
|
||||
cmd.Flags().Int(fl.Name, d, desc)
|
||||
case "int64":
|
||||
var d int64
|
||||
fmt.Sscanf(fl.Default, "%d", &d)
|
||||
cmd.Flags().Int64(fl.Name, d, desc)
|
||||
case "float64":
|
||||
var d float64
|
||||
fmt.Sscanf(fl.Default, "%g", &d)
|
||||
@@ -1105,8 +1107,11 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("dry-run", false, "print request without executing")
|
||||
if s.HasFormat {
|
||||
if cmd.Flags().Lookup("format") == nil {
|
||||
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" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
@@ -1117,9 +1122,4 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
39
shortcuts/common/runner_format_universal_test.go
Normal file
39
shortcuts/common/runner_format_universal_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestShortcutMount_FormatFlagAlwaysRegistered verifies that --format is
|
||||
// injected for every shortcut regardless of the HasFormat field value.
|
||||
func TestShortcutMount_FormatFlagAlwaysRegistered(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "im",
|
||||
Command: "+message-send",
|
||||
Description: "send message",
|
||||
HasFormat: false, // explicitly false — format must still be registered
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+message-send"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
flag := cmd.Flags().Lookup("format")
|
||||
if flag == nil {
|
||||
t.Fatal("--format flag not registered; expected it to be injected even when HasFormat is false")
|
||||
}
|
||||
if flag.DefValue != "json" {
|
||||
t.Errorf("--format default = %q, want %q", flag.DefValue, "json")
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
// Flag describes a CLI flag for a shortcut.
|
||||
type Flag struct {
|
||||
Name string // flag name (e.g. "calendar-id")
|
||||
Type string // "string" (default) | "bool" | "int" | "int64" | "float64" | "string_array" | "string_slice"
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
@@ -49,7 +49,7 @@ type Shortcut struct {
|
||||
// Declarative fields (new framework).
|
||||
AuthTypes []string // supported identities: "user", "bot" (default: ["user"])
|
||||
Flags []Flag // flag definitions; --dry-run is auto-injected
|
||||
HasFormat bool // auto-inject --format flag (json|pretty|table|ndjson|csv)
|
||||
HasFormat bool // Deprecated: --format is now always injected; this field has no effect.
|
||||
Tips []string // optional tips shown in --help output
|
||||
Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement
|
||||
|
||||
@@ -58,6 +58,14 @@ type Shortcut struct {
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
|
||||
|
||||
// OnInvoke, when non-nil, runs from the command's cobra PreRunE — before
|
||||
// cobra validates required flags — so its side effect fires even when the
|
||||
// call later fails on a missing required flag (which short-circuits before
|
||||
// Validate/Execute). The backward-compat aliases use it to record a
|
||||
// deprecation notice that must surface regardless of whether the call
|
||||
// validates. Fire-and-forget: no args, no return (e.g. deprecation.SetPending).
|
||||
OnInvoke func()
|
||||
|
||||
// PrintFlagSchema, when non-nil, opts this shortcut into the
|
||||
// `--print-schema --flag-name <name>` runtime introspection contract.
|
||||
// The framework auto-injects those two system flags and short-circuits
|
||||
|
||||
@@ -6,7 +6,9 @@ package shortcuts
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/okr"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/apps"
|
||||
@@ -29,6 +32,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/markdown"
|
||||
"github.com/larksuite/cli/shortcuts/minutes"
|
||||
"github.com/larksuite/cli/shortcuts/sheets"
|
||||
sheetsbackward "github.com/larksuite/cli/shortcuts/sheets/backward"
|
||||
"github.com/larksuite/cli/shortcuts/slides"
|
||||
"github.com/larksuite/cli/shortcuts/task"
|
||||
"github.com/larksuite/cli/shortcuts/vc"
|
||||
@@ -53,35 +57,104 @@ func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool {
|
||||
return slices.Contains(allowed, brand)
|
||||
}
|
||||
|
||||
// allShortcuts aggregates shortcuts from all domain packages.
|
||||
var allShortcuts []common.Shortcut
|
||||
// baseShortcuts aggregates every shortcut except sheets. Sheets are kept out of
|
||||
// the global init path because their registration currently pulls in large
|
||||
// embedded flag metadata; we only mount them when the current invocation is
|
||||
// actually targeting sheets (or when completion/help needs the full tree).
|
||||
var baseShortcuts []common.Shortcut
|
||||
|
||||
func init() {
|
||||
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, im.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, contact_shortcuts.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, sheets.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, base.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, event.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, task.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, apps.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, calendar.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, doc.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, drive.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, im.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, contact_shortcuts.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, base.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, event.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, mail.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, markdown.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, slides.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, minutes.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, task.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, vc.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, whiteboard.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, wiki.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, okr.Shortcuts()...)
|
||||
}
|
||||
|
||||
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
|
||||
//
|
||||
//go:noinline
|
||||
func AllShortcuts() []common.Shortcut {
|
||||
return append([]common.Shortcut(nil), allShortcuts...)
|
||||
return append([]common.Shortcut(nil), allShortcuts(true)...)
|
||||
}
|
||||
|
||||
func allShortcuts(includeSheets bool) []common.Shortcut {
|
||||
out := append([]common.Shortcut(nil), baseShortcuts...)
|
||||
if includeSheets {
|
||||
out = append(out, sheets.Shortcuts()...)
|
||||
// Backward-compatible sheets shortcuts (pre-refactor command names),
|
||||
// kept under shortcuts/sheets/backward so external callers relying on the
|
||||
// old `+create`, `+read`, `+write`, ... commands keep working alongside the
|
||||
// refactored ones. Command names are disjoint from sheets.Shortcuts().
|
||||
out = append(out, wrapSheetsBackwardDeprecation(sheetsbackward.Shortcuts())...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func shouldIncludeSheetsShortcuts(_ *cmdutil.Factory) bool {
|
||||
args := os.Args[1:]
|
||||
if len(args) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
rootNames := map[string]struct{}{
|
||||
"api": {},
|
||||
"auth": {},
|
||||
"completion": {},
|
||||
"config": {},
|
||||
"doctor": {},
|
||||
"event": {},
|
||||
"help": {},
|
||||
"profile": {},
|
||||
"schema": {},
|
||||
"update": {},
|
||||
}
|
||||
for _, sc := range baseShortcuts {
|
||||
rootNames[sc.Service] = struct{}{}
|
||||
}
|
||||
rootNames["sheets"] = struct{}{}
|
||||
rootNames["__complete"] = struct{}{}
|
||||
rootNames["__completeNoDesc"] = struct{}{}
|
||||
|
||||
for i, arg := range args {
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
if arg == "help" {
|
||||
for _, next := range args[i+1:] {
|
||||
if strings.HasPrefix(next, "-") {
|
||||
continue
|
||||
}
|
||||
return next == "sheets"
|
||||
}
|
||||
return true
|
||||
}
|
||||
if _, ok := rootNames[arg]; !ok {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "sheets", "completion", "__complete", "__completeNoDesc":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown argv shape: keep the pre-change conservative behavior and mount
|
||||
// the full tree rather than accidentally hiding a command.
|
||||
return true
|
||||
}
|
||||
|
||||
// RegisterShortcuts registers all +shortcut commands on the program.
|
||||
@@ -100,7 +173,7 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
|
||||
// Group by service
|
||||
byService := make(map[string][]common.Shortcut)
|
||||
for _, s := range allShortcuts {
|
||||
for _, s := range allShortcuts(shouldIncludeSheetsShortcuts(f)) {
|
||||
byService[s.Service] = append(byService[s.Service], s)
|
||||
}
|
||||
|
||||
@@ -146,6 +219,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
if service == "mail" {
|
||||
mail.InstallOnMail(svc)
|
||||
}
|
||||
if service == "sheets" {
|
||||
applySheetsCompatGroups(svc)
|
||||
}
|
||||
|
||||
if !IsShortcutServiceAvailable(service, brand) {
|
||||
installBrandRestrictionGuard(svc, service, brand)
|
||||
@@ -189,3 +265,153 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
|
||||
// --help bypasses RunE, so surface the restriction in Long too.
|
||||
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
|
||||
}
|
||||
|
||||
// Sheets backward-compatibility help grouping.
|
||||
//
|
||||
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that
|
||||
// users whose lark-sheets skill predates the refactor keep working even after
|
||||
// upgrading only the binary. In `sheets --help` those aliases would otherwise
|
||||
// sort alphabetically into the same flat list as the current commands,
|
||||
// indistinguishable from them. applySheetsCompatGroups splits them into a
|
||||
// dedicated cobra group whose heading tells the user to update their skill, and
|
||||
// appends a "(→ +new-command)" pointer to each alias so the migration target is
|
||||
// obvious. Pure presentation — the aliases stay fully executable.
|
||||
const (
|
||||
sheetsCurrentGroupID = "sheets-current"
|
||||
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
|
||||
// `sheets --help` grouping and the generic unknown-subcommand path
|
||||
// (cmd/root.go) classify these aliases the same way.
|
||||
sheetsDeprecatedGroupID = cmdutil.DeprecatedGroupID
|
||||
)
|
||||
|
||||
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current
|
||||
// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases
|
||||
// absent from this map still land in the deprecated group, just without a
|
||||
// pointer, so a missing entry degrades gracefully rather than misgrouping.
|
||||
var sheetsAliasReplacement = map[string]string{
|
||||
// spreadsheet / sheet management
|
||||
"+create": "+workbook-create",
|
||||
"+info": "+workbook-info",
|
||||
"+export": "+workbook-export",
|
||||
"+create-sheet": "+sheet-create",
|
||||
"+copy-sheet": "+sheet-copy",
|
||||
"+delete-sheet": "+sheet-delete",
|
||||
"+update-sheet": "+sheet-rename / +sheet-move / …",
|
||||
// cell data
|
||||
"+read": "+cells-get",
|
||||
"+write": "+cells-set",
|
||||
"+append": "+cells-set",
|
||||
"+find": "+cells-search",
|
||||
"+replace": "+cells-replace",
|
||||
// cell style / merge / image
|
||||
"+set-style": "+cells-set-style",
|
||||
"+batch-set-style": "+cells-batch-set-style",
|
||||
"+merge-cells": "+cells-merge",
|
||||
"+unmerge-cells": "+cells-unmerge",
|
||||
"+write-image": "+cells-set-image",
|
||||
// row / column dimensions
|
||||
"+add-dimension": "+dim-insert",
|
||||
"+insert-dimension": "+dim-insert",
|
||||
"+update-dimension": "+rows-resize / +dim-hide / …",
|
||||
"+move-dimension": "+dim-move",
|
||||
"+delete-dimension": "+dim-delete",
|
||||
// filter views (conditions folded into the view flags)
|
||||
"+create-filter-view": "+filter-view-create",
|
||||
"+update-filter-view": "+filter-view-update",
|
||||
"+list-filter-views": "+filter-view-list",
|
||||
"+get-filter-view": "+filter-view-list",
|
||||
"+delete-filter-view": "+filter-view-delete",
|
||||
"+create-filter-view-condition": "+filter-view-update",
|
||||
"+update-filter-view-condition": "+filter-view-update",
|
||||
"+list-filter-view-conditions": "+filter-view-list",
|
||||
"+get-filter-view-condition": "+filter-view-list",
|
||||
"+delete-filter-view-condition": "+filter-view-update",
|
||||
// dropdowns
|
||||
"+set-dropdown": "+dropdown-set",
|
||||
"+update-dropdown": "+dropdown-update",
|
||||
"+get-dropdown": "+dropdown-get",
|
||||
"+delete-dropdown": "+dropdown-delete",
|
||||
// float images (media-upload folded into create)
|
||||
"+media-upload": "+float-image-create",
|
||||
"+create-float-image": "+float-image-create",
|
||||
"+update-float-image": "+float-image-update",
|
||||
"+get-float-image": "+float-image-list",
|
||||
"+list-float-images": "+float-image-list",
|
||||
"+delete-float-image": "+float-image-delete",
|
||||
}
|
||||
|
||||
func applySheetsCompatGroups(svc *cobra.Command) {
|
||||
svc.AddGroup(
|
||||
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
|
||||
&cobra.Group{
|
||||
ID: sheetsDeprecatedGroupID,
|
||||
Title: "Deprecated pre-refactor commands (still work) — update your lark-sheets skill, then: lark-cli update",
|
||||
},
|
||||
)
|
||||
|
||||
deprecated := make(map[string]struct{})
|
||||
for _, s := range sheetsbackward.Shortcuts() {
|
||||
deprecated[s.Command] = struct{}{}
|
||||
}
|
||||
|
||||
for _, c := range svc.Commands() {
|
||||
name := c.Name()
|
||||
if _, ok := deprecated[name]; ok {
|
||||
c.GroupID = sheetsDeprecatedGroupID
|
||||
if repl := sheetsAliasReplacement[name]; repl != "" {
|
||||
c.Short = c.Short + " (→ " + repl + ")"
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Only the refactored shortcuts (all "+"-prefixed) belong in the current
|
||||
// group. Leave the OpenAPI metaapi subcommands (spreadsheets, ...) and the
|
||||
// auto-added help/completion ungrouped so cobra files them under
|
||||
// "Additional Commands".
|
||||
if len(name) > 0 && name[0] == '+' {
|
||||
c.GroupID = sheetsCurrentGroupID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets
|
||||
// alias so that invoking it records a process-level deprecation notice, which
|
||||
// cmd/root.go surfaces in the JSON "_notice" envelope. This reaches the users
|
||||
// the --help grouping cannot: those whose pre-refactor skill calls +read /
|
||||
// +write directly and never reads --help. Replacement targets come from
|
||||
// sheetsAliasReplacement — the same single source of truth that drives the
|
||||
// "(→ +new)" help pointers.
|
||||
func wrapSheetsBackwardDeprecation(list []common.Shortcut) []common.Shortcut {
|
||||
for i := range list {
|
||||
notice := &deprecation.Notice{
|
||||
Command: list[i].Command,
|
||||
Replacement: sheetsAliasReplacement[list[i].Command],
|
||||
Skill: "lark-sheets",
|
||||
}
|
||||
// Record the notice as soon as the command's own logic runs, so it is
|
||||
// surfaced even when Validate rejects the call — an out-of-date skill
|
||||
// can pass pre-refactor argument shapes (e.g. a range without the new
|
||||
// sheet-id prefix) and fail validation before Execute — and when
|
||||
// --dry-run short-circuits before Execute. Both hooks store the same
|
||||
// pointer, so setting it twice is harmless.
|
||||
if origValidate := list[i].Validate; origValidate != nil {
|
||||
list[i].Validate = func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
deprecation.SetPending(notice)
|
||||
return origValidate(ctx, runtime)
|
||||
}
|
||||
}
|
||||
if origExecute := list[i].Execute; origExecute != nil {
|
||||
list[i].Execute = func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
deprecation.SetPending(notice)
|
||||
return origExecute(ctx, runtime)
|
||||
}
|
||||
}
|
||||
// The Validate/Execute wrappers above miss one path: a cobra-level
|
||||
// required flag (MarkFlagRequired) that is absent fails at
|
||||
// ValidateRequiredFlags, before RunE — so neither hook runs and the
|
||||
// notice would be lost on exactly the "stale skill calls the old command
|
||||
// and mis-supplies flags" case it exists for. OnInvoke runs from PreRunE,
|
||||
// ahead of ValidateRequiredFlags, so the notice still surfaces there.
|
||||
list[i].OnInvoke = func() { deprecation.SetPending(notice) }
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package shortcuts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -16,7 +17,9 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -46,8 +49,15 @@ func newRegisterTestProgramWithTipsHelp() *cobra.Command {
|
||||
return program
|
||||
}
|
||||
|
||||
func withRegisterTestArgs(t *testing.T, args ...string) {
|
||||
t.Helper()
|
||||
orig := os.Args
|
||||
os.Args = append([]string{"lark-cli"}, args...)
|
||||
t.Cleanup(func() { os.Args = orig })
|
||||
}
|
||||
|
||||
func TestAllShortcutsScopesNotNil(t *testing.T) {
|
||||
for _, s := range allShortcuts {
|
||||
for _, s := range AllShortcuts() {
|
||||
hasScopes := s.Scopes != nil || s.UserScopes != nil || s.BotScopes != nil
|
||||
if !hasScopes {
|
||||
t.Errorf("shortcut %s/%s: Scopes is nil (must be explicitly set, use []string{} if no scopes needed)", s.Service, s.Command)
|
||||
@@ -107,6 +117,30 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcuts_SkipsSheetsWhenInvocationTargetsOtherService(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
withRegisterTestArgs(t, "auth", "status")
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
if _, _, err := program.Find([]string{"sheets", "+workbook-info"}); err == nil {
|
||||
t.Fatal("unexpected sheets shortcut mounted for non-sheets invocation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcuts_IncludesSheetsWhenInvocationTargetsSheets(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
withRegisterTestArgs(t, "sheets", "+workbook-info")
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
cmd, _, err := program.Find([]string{"sheets", "+workbook-info"})
|
||||
if err != nil {
|
||||
t.Fatalf("find sheets shortcut: %v", err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != "+workbook-info" {
|
||||
t.Fatalf("sheets shortcut not mounted: %#v", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Service-level cobra commands created by RegisterShortcuts must carry
|
||||
// the cmdmeta.Domain annotation so plugin Selectors (platform.ByDomain)
|
||||
// and Rule.Allow path-globs can resolve a command's business domain.
|
||||
@@ -471,3 +505,152 @@ func TestGenerateShortcutsJSON(t *testing.T) {
|
||||
}
|
||||
t.Logf("wrote %d bytes to %s", len(data), output)
|
||||
}
|
||||
|
||||
// applySheetsCompatGroups must split the sheets service into a current group
|
||||
// (refactored "+"-shortcuts) and a deprecated group (backward-compat aliases),
|
||||
// append a "(→ +new)" migration pointer to each alias, and leave non-"+"
|
||||
// subcommands (OpenAPI metaapi, help/completion) ungrouped so cobra files them
|
||||
// under "Additional Commands".
|
||||
func TestApplySheetsCompatGroups(t *testing.T) {
|
||||
svc := &cobra.Command{Use: "sheets"}
|
||||
newCmd := &cobra.Command{Use: "+cells-get", Short: "Read ranges"}
|
||||
aliasCmd := &cobra.Command{Use: "+read", Short: "Read spreadsheet cell values"}
|
||||
metaCmd := &cobra.Command{Use: "spreadsheets", Short: "spreadsheets operations"}
|
||||
svc.AddCommand(newCmd, aliasCmd, metaCmd)
|
||||
|
||||
applySheetsCompatGroups(svc)
|
||||
|
||||
if !svc.ContainsGroup(sheetsCurrentGroupID) {
|
||||
t.Errorf("current group %q not registered", sheetsCurrentGroupID)
|
||||
}
|
||||
if !svc.ContainsGroup(sheetsDeprecatedGroupID) {
|
||||
t.Errorf("deprecated group %q not registered", sheetsDeprecatedGroupID)
|
||||
}
|
||||
if newCmd.GroupID != sheetsCurrentGroupID {
|
||||
t.Errorf("+cells-get GroupID = %q, want %q", newCmd.GroupID, sheetsCurrentGroupID)
|
||||
}
|
||||
if aliasCmd.GroupID != sheetsDeprecatedGroupID {
|
||||
t.Errorf("+read GroupID = %q, want %q", aliasCmd.GroupID, sheetsDeprecatedGroupID)
|
||||
}
|
||||
if !strings.Contains(aliasCmd.Short, "(→ +cells-get)") {
|
||||
t.Errorf("+read Short missing migration pointer, got %q", aliasCmd.Short)
|
||||
}
|
||||
if metaCmd.GroupID != "" {
|
||||
t.Errorf("metaapi spreadsheets should stay ungrouped, got GroupID %q", metaCmd.GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: the rendered `sheets --help` must surface the deprecated-group
|
||||
// heading (telling users to update their skill) plus the per-alias migration
|
||||
// pointers, while keeping the refactored shortcuts under Available Commands.
|
||||
func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
sheetsCmd, _, err := program.Find([]string{"sheets"})
|
||||
if err != nil {
|
||||
t.Fatalf("find sheets command: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
sheetsCmd.SetOut(&out)
|
||||
if err := sheetsCmd.Help(); err != nil {
|
||||
t.Fatalf("sheets help failed: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Available Commands:",
|
||||
"Deprecated pre-refactor commands",
|
||||
"update your lark-sheets skill",
|
||||
"+read",
|
||||
"(→ +cells-get)",
|
||||
"+write",
|
||||
"(→ +cells-set)",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("sheets help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that
|
||||
// invoking it records a process-level deprecation notice (reusing
|
||||
// sheetsAliasReplacement for the migration target) while still calling the
|
||||
// original Execute. cmd/root.go reads that notice into the JSON "_notice".
|
||||
func TestWrapSheetsBackwardDeprecation(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
called := false
|
||||
in := []common.Shortcut{{
|
||||
Service: "sheets",
|
||||
Command: "+read",
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
|
||||
out := wrapSheetsBackwardDeprecation(in)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("wrapped list len = %d, want 1", len(out))
|
||||
}
|
||||
if deprecation.GetPending() != nil {
|
||||
t.Fatal("notice set before wrapped Execute ran")
|
||||
}
|
||||
|
||||
if err := out[0].Execute(context.Background(), nil); err != nil {
|
||||
t.Fatalf("wrapped Execute returned error: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("original Execute was not invoked by the wrapper")
|
||||
}
|
||||
|
||||
dep := deprecation.GetPending()
|
||||
if dep == nil {
|
||||
t.Fatal("expected a pending deprecation notice after Execute")
|
||||
}
|
||||
if dep.Command != "+read" {
|
||||
t.Errorf("notice Command = %q, want +read", dep.Command)
|
||||
}
|
||||
if dep.Replacement != "+cells-get" {
|
||||
t.Errorf("notice Replacement = %q, want +cells-get (from sheetsAliasReplacement)", dep.Replacement)
|
||||
}
|
||||
if dep.Skill != "lark-sheets" {
|
||||
t.Errorf("notice Skill = %q, want lark-sheets", dep.Skill)
|
||||
}
|
||||
}
|
||||
|
||||
// The wrapper must also decorate Validate, so an out-of-date skill whose
|
||||
// pre-refactor argument shape fails validation (before Execute) still gets the
|
||||
// deprecation notice in its error envelope.
|
||||
func TestWrapSheetsBackwardDeprecationValidateHook(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
validated := false
|
||||
in := []common.Shortcut{{
|
||||
Service: "sheets",
|
||||
Command: "+write",
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
validated = true
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
|
||||
out := wrapSheetsBackwardDeprecation(in)
|
||||
if out[0].Validate == nil {
|
||||
t.Fatal("Validate hook was dropped by the wrapper")
|
||||
}
|
||||
if err := out[0].Validate(context.Background(), nil); err != nil {
|
||||
t.Fatalf("wrapped Validate returned error: %v", err)
|
||||
}
|
||||
if !validated {
|
||||
t.Fatal("original Validate was not invoked")
|
||||
}
|
||||
dep := deprecation.GetPending()
|
||||
if dep == nil || dep.Command != "+write" || dep.Replacement != "+cells-set" {
|
||||
t.Fatalf("Validate hook did not record expected notice: %#v", dep)
|
||||
}
|
||||
}
|
||||
|
||||
239
shortcuts/sheets/backward/helpers.go
Normal file
239
shortcuts/sheets/backward/helpers.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package backward
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var (
|
||||
singleCellRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*$`)
|
||||
cellSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+[1-9][0-9]*$`)
|
||||
cellToColRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+$`)
|
||||
colSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+:[A-Za-z]+$`)
|
||||
rowSpanRangePattern = regexp.MustCompile(`^[1-9][0-9]*:[1-9][0-9]*$`)
|
||||
cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`)
|
||||
)
|
||||
|
||||
var sheetRangeSeparatorReplacer = strings.NewReplacer(`\!`, "!", `\!`, "!", "!", "!")
|
||||
|
||||
// getFirstSheetID queries the spreadsheet and returns the first sheet's ID.
|
||||
func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
|
||||
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
if len(sheets) > 0 {
|
||||
sheet, _ := sheets[0].(map[string]interface{})
|
||||
if id, ok := sheet["sheet_id"].(string); ok && id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet")
|
||||
}
|
||||
|
||||
// extractSpreadsheetToken extracts spreadsheet token from URL.
|
||||
func extractSpreadsheetToken(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
prefixes := []string{"/sheets/", "/spreadsheets/"}
|
||||
for _, prefix := range prefixes {
|
||||
if idx := strings.Index(input, prefix); idx >= 0 {
|
||||
token := input[idx+len(prefix):]
|
||||
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
|
||||
token = token[:idx2]
|
||||
}
|
||||
return token
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func normalizeSheetRange(sheetID, input string) string {
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" || strings.Contains(input, "!") || sheetID == "" {
|
||||
return input
|
||||
}
|
||||
if looksLikeRelativeRange(input) {
|
||||
return sheetID + "!" + input
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func normalizePointRange(sheetID, input string) string {
|
||||
input = normalizeSheetRange(sheetID, input)
|
||||
if input == "" {
|
||||
return input
|
||||
}
|
||||
rangeSheetID, subRange, ok := splitSheetRange(input)
|
||||
if !ok || !singleCellRangePattern.MatchString(subRange) {
|
||||
return input
|
||||
}
|
||||
return rangeSheetID + "!" + subRange + ":" + subRange
|
||||
}
|
||||
|
||||
func normalizeWriteRange(sheetID, input string, values interface{}) string {
|
||||
rows, cols := matrixDimensions(values)
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" {
|
||||
return buildRectRange(sheetID, "A1", rows, cols)
|
||||
}
|
||||
|
||||
input = normalizeSheetRange(sheetID, input)
|
||||
rangeSheetID, subRange, ok := splitSheetRange(input)
|
||||
if !ok {
|
||||
return buildRectRange(input, "A1", rows, cols)
|
||||
}
|
||||
if singleCellRangePattern.MatchString(subRange) {
|
||||
return buildRectRange(rangeSheetID, subRange, rows, cols)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func validateSheetRangeInput(sheetID, input string) error {
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" || strings.Contains(input, "!") || sheetID != "" {
|
||||
return nil
|
||||
}
|
||||
if looksLikeRelativeRange(input) {
|
||||
return common.FlagErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSingleCellRange rejects multi-cell spans (e.g. "A1:B2") that are
|
||||
// invalid for single-cell operations like write-image. Empty and single-cell
|
||||
// values pass through.
|
||||
func validateSingleCellRange(input string) error {
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" {
|
||||
return nil
|
||||
}
|
||||
// Extract the sub-range after the sheet ID prefix, if present.
|
||||
subRange := input
|
||||
if _, sr, ok := splitSheetRange(input); ok {
|
||||
subRange = sr
|
||||
}
|
||||
if cellSpanRangePattern.MatchString(subRange) {
|
||||
parts := strings.SplitN(subRange, ":", 2)
|
||||
if strings.EqualFold(parts[0], parts[1]) {
|
||||
return nil
|
||||
}
|
||||
return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func looksLikeRelativeRange(input string) bool {
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" {
|
||||
return false
|
||||
}
|
||||
return singleCellRangePattern.MatchString(input) ||
|
||||
cellSpanRangePattern.MatchString(input) ||
|
||||
cellToColRangePattern.MatchString(input) ||
|
||||
colSpanRangePattern.MatchString(input) ||
|
||||
rowSpanRangePattern.MatchString(input)
|
||||
}
|
||||
|
||||
func splitSheetRange(input string) (sheetID, subRange string, ok bool) {
|
||||
parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
func normalizeSheetRangeSeparators(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return input
|
||||
}
|
||||
return sheetRangeSeparatorReplacer.Replace(input)
|
||||
}
|
||||
|
||||
func buildRectRange(sheetID, anchor string, rows, cols int) string {
|
||||
if sheetID == "" {
|
||||
return ""
|
||||
}
|
||||
if rows < 1 {
|
||||
rows = 1
|
||||
}
|
||||
if cols < 1 {
|
||||
cols = 1
|
||||
}
|
||||
endCell, err := offsetCell(anchor, rows-1, cols-1)
|
||||
if err != nil {
|
||||
return sheetID
|
||||
}
|
||||
return sheetID + "!" + anchor + ":" + endCell
|
||||
}
|
||||
|
||||
func matrixDimensions(values interface{}) (rows, cols int) {
|
||||
rowList, ok := values.([]interface{})
|
||||
if !ok || len(rowList) == 0 {
|
||||
return 1, 1
|
||||
}
|
||||
rows = len(rowList)
|
||||
for _, row := range rowList {
|
||||
if cells, ok := row.([]interface{}); ok && len(cells) > cols {
|
||||
cols = len(cells)
|
||||
}
|
||||
}
|
||||
if cols == 0 {
|
||||
cols = 1
|
||||
}
|
||||
return rows, cols
|
||||
}
|
||||
|
||||
func offsetCell(cell string, rowOffset, colOffset int) (string, error) {
|
||||
matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell))
|
||||
if len(matches) != 3 {
|
||||
return "", fmt.Errorf("invalid cell reference: %s", cell)
|
||||
}
|
||||
colIndex := columnNameToIndex(matches[1])
|
||||
if colIndex < 1 {
|
||||
return "", fmt.Errorf("invalid column: %s", matches[1])
|
||||
}
|
||||
rowIndex, err := strconv.Atoi(matches[2])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil
|
||||
}
|
||||
|
||||
func columnNameToIndex(name string) int {
|
||||
name = strings.ToUpper(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return 0
|
||||
}
|
||||
index := 0
|
||||
for _, r := range name {
|
||||
if r < 'A' || r > 'Z' {
|
||||
return 0
|
||||
}
|
||||
index = index*26 + int(r-'A'+1)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func columnIndexToName(index int) string {
|
||||
if index < 1 {
|
||||
return ""
|
||||
}
|
||||
var out []byte
|
||||
for index > 0 {
|
||||
index--
|
||||
out = append([]byte{byte('A' + index%26)}, out...)
|
||||
index /= 26
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user