mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
776ee686ff | ||
|
|
4da6d610e2 | ||
|
|
3f4352d50c | ||
|
|
543a8365d6 | ||
|
|
0192cee859 | ||
|
|
18e227f281 | ||
|
|
7e9beec422 | ||
|
|
462d38e8f7 | ||
|
|
e4d263948c | ||
|
|
11191df703 | ||
|
|
e23b3a8dc6 | ||
|
|
f3699298aa | ||
|
|
018eeb6414 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,6 +2,29 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.17] - 2026-04-22
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Use `Content-Disposition` filename when downloading message resources (#536)
|
||||
- **drive**: Add `+apply-permission` to request doc access (#588)
|
||||
- Support record share link (#466)
|
||||
- **whiteboard**: Add image support to `whiteboard-cli` skill (#553)
|
||||
- **cmdutil**: Add `X-Cli-Build` header for CLI build classification (#596)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Add default-table follow-up hint to `base-create` (#600)
|
||||
- Skip flag-completion registration outside completion path (#598)
|
||||
- Add `record-share-link-create` in `SKILL.md` (#597)
|
||||
- **mail**: Remove leftover conflict marker in skill docs (#594)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Clarify that comment listing defaults to unresolved comments only (#609)
|
||||
- **doc**: Fix `--markdown` examples that teach literal `\n` (#602)
|
||||
- **mail**: Remove `get_signatures` from skill reference, exposed via `+signature` instead (#545)
|
||||
|
||||
## [v1.0.16] - 2026-04-21
|
||||
|
||||
### Features
|
||||
@@ -441,6 +464,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.17]: https://github.com/larksuite/cli/releases/tag/v1.0.17
|
||||
[v1.0.16]: https://github.com/larksuite/cli/releases/tag/v1.0.16
|
||||
[v1.0.15]: https://github.com/larksuite/cli/releases/tag/v1.0.15
|
||||
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14
|
||||
|
||||
@@ -100,7 +100,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
|
||||
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ func Execute() int {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
f, rootCmd := buildInternal(
|
||||
context.Background(), inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
@@ -153,6 +155,12 @@ func isCompletionCommand(args []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
|
||||
// the invocation will actually serve a __complete request.
|
||||
func configureFlagCompletions(args []string) {
|
||||
cmdutil.SetFlagCompletionsDisabled(!isCompletionCommand(args))
|
||||
}
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
// and returns the process exit code.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
|
||||
@@ -196,3 +196,28 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureFlagCompletions(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantDisabled bool
|
||||
}{
|
||||
{"plain command", []string{"im", "+send"}, true},
|
||||
{"help flag", []string{"im", "--help"}, true},
|
||||
{"no args", []string{}, true},
|
||||
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
|
||||
{"completion subcommand", []string{"completion", "bash"}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmdutil.SetFlagCompletionsDisabled(!tc.wantDisabled)
|
||||
configureFlagCompletions(tc.args)
|
||||
if got := cmdutil.FlagCompletionsDisabled(); got != tc.wantDisabled {
|
||||
t.Fatalf("FlagCompletionsDisabled() = %v, want %v", got, tc.wantDisabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +377,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
|
||||
cmd.ValidArgsFunction = completeSchemaPath(f)
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
}
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
|
||||
37
internal/cmdutil/completion.go
Normal file
37
internal/cmdutil/completion.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Cobra keeps completion callbacks in a package-global map keyed by
|
||||
// *pflag.Flag with no removal path, so registrations made for a *cobra.Command
|
||||
// outlive the command itself. Skip registration when the current invocation
|
||||
// will not serve a completion request.
|
||||
var flagCompletionsDisabled atomic.Bool
|
||||
|
||||
// SetFlagCompletionsDisabled switches RegisterFlagCompletion between
|
||||
// registering and no-op. Typically set once at process start.
|
||||
func SetFlagCompletionsDisabled(disabled bool) {
|
||||
flagCompletionsDisabled.Store(disabled)
|
||||
}
|
||||
|
||||
// FlagCompletionsDisabled reports the current switch state.
|
||||
func FlagCompletionsDisabled() bool {
|
||||
return flagCompletionsDisabled.Load()
|
||||
}
|
||||
|
||||
// RegisterFlagCompletion wraps (*cobra.Command).RegisterFlagCompletionFunc
|
||||
// and honors the package switch. The underlying error is swallowed to match
|
||||
// the `_ = cmd.RegisterFlagCompletionFunc(...)` style already used here.
|
||||
func RegisterFlagCompletion(cmd *cobra.Command, flagName string, fn cobra.CompletionFunc) {
|
||||
if flagCompletionsDisabled.Load() {
|
||||
return
|
||||
}
|
||||
_ = cmd.RegisterFlagCompletionFunc(flagName, fn)
|
||||
}
|
||||
78
internal/cmdutil/completion_test.go
Normal file
78
internal/cmdutil/completion_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSetFlagCompletionsDisabled_RoundTrip(t *testing.T) {
|
||||
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
|
||||
|
||||
if FlagCompletionsDisabled() {
|
||||
t.Fatal("expected default false")
|
||||
}
|
||||
SetFlagCompletionsDisabled(true)
|
||||
if !FlagCompletionsDisabled() {
|
||||
t.Fatal("expected true after Set(true)")
|
||||
}
|
||||
SetFlagCompletionsDisabled(false)
|
||||
if FlagCompletionsDisabled() {
|
||||
t.Fatal("expected false after Set(false)")
|
||||
}
|
||||
}
|
||||
|
||||
// When disabled, a *cobra.Command must be collectable after the caller drops
|
||||
// its reference — i.e. the wrapper did not touch cobra's global map.
|
||||
func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
|
||||
SetFlagCompletionsDisabled(true)
|
||||
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
|
||||
|
||||
const N = 5
|
||||
var collected atomic.Int32
|
||||
func() {
|
||||
for range N {
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
cmd.Flags().String("foo", "", "")
|
||||
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
runtime.SetFinalizer(cmd, func(_ *cobra.Command) { collected.Add(1) })
|
||||
}
|
||||
}()
|
||||
// Finalizers run on a dedicated goroutine after GC; loop to give it time.
|
||||
for range 30 {
|
||||
runtime.GC()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if got := collected.Load(); int(got) != N {
|
||||
t.Fatalf("expected %d *cobra.Command finalizers to fire when completions disabled, got %d", N, got)
|
||||
}
|
||||
}
|
||||
|
||||
// When enabled, the registered completion must be reachable via cobra.
|
||||
func TestRegisterFlagCompletion_Enabled_DoesRegister(t *testing.T) {
|
||||
SetFlagCompletionsDisabled(false)
|
||||
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
cmd.Flags().String("foo", "", "")
|
||||
want := []cobra.Completion{"a", "b"}
|
||||
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
return want, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
fn, ok := cmd.GetFlagCompletionFunc("foo")
|
||||
if !ok {
|
||||
t.Fatal("expected completion func to be registered")
|
||||
}
|
||||
got, _ := fn(cmd, nil, "")
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("unexpected completion result: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = util.SharedTransport()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
return wrapWithExtension(sdkTransport)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func addIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target
|
||||
}
|
||||
|
||||
registerIdentityFlag(cmd, target, cfg.defaultValue, cfg.usage)
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return cfg.completionValues, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,14 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -14,12 +21,21 @@ import (
|
||||
const (
|
||||
HeaderSource = "X-Cli-Source"
|
||||
HeaderVersion = "X-Cli-Version"
|
||||
HeaderBuild = "X-Cli-Build"
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
HeaderUserAgent = "User-Agent"
|
||||
|
||||
// BuildKindOfficial / BuildKindExtended / BuildKindUnknown are the values
|
||||
// reported in the X-Cli-Build header; see DetectBuildKind for semantics.
|
||||
BuildKindOfficial = "official"
|
||||
BuildKindExtended = "extended"
|
||||
BuildKindUnknown = "unknown"
|
||||
|
||||
officialModulePath = "github.com/larksuite/cli"
|
||||
)
|
||||
|
||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||
@@ -32,10 +48,108 @@ func BaseSecurityHeaders() http.Header {
|
||||
h := make(http.Header)
|
||||
h.Set(HeaderSource, SourceValue)
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
return h
|
||||
}
|
||||
|
||||
var (
|
||||
buildKindOnce sync.Once
|
||||
buildKindVal string
|
||||
)
|
||||
|
||||
// DetectBuildKind reports whether this binary is the official CLI, an
|
||||
// extended/repackaged build, or unknown. The result is cached via sync.Once
|
||||
// so it is computed only on the first call.
|
||||
//
|
||||
// IMPORTANT: must NOT be called from any package init(). Go's init ordering
|
||||
// follows the import graph; ISV providers registered via blank import may not
|
||||
// have run yet, which would misclassify an extended build as official. Call
|
||||
// only when handling an actual request (e.g. from BaseSecurityHeaders).
|
||||
func DetectBuildKind() string {
|
||||
buildKindOnce.Do(func() {
|
||||
buildKindVal = computeBuildKind()
|
||||
})
|
||||
return buildKindVal
|
||||
}
|
||||
|
||||
// computeBuildKind performs the actual detection without any caching.
|
||||
// Exposed for tests. Gathers runtime/global inputs and delegates the pure
|
||||
// branching logic to classifyBuild so that logic can be unit-tested without
|
||||
// mutating process-wide provider registries.
|
||||
func computeBuildKind() string {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
mainPath := ""
|
||||
if ok {
|
||||
mainPath = info.Main.Path
|
||||
}
|
||||
|
||||
credProviders := credential.Providers()
|
||||
creds := make([]any, len(credProviders))
|
||||
for i, p := range credProviders {
|
||||
creds[i] = p
|
||||
}
|
||||
|
||||
var tp any
|
||||
if p := exttransport.GetProvider(); p != nil {
|
||||
tp = p
|
||||
}
|
||||
var fp any
|
||||
if p := fileio.GetProvider(); p != nil {
|
||||
fp = p
|
||||
}
|
||||
return classifyBuild(mainPath, ok, creds, tp, fp)
|
||||
}
|
||||
|
||||
// classifyBuild is the pure classification logic used by computeBuildKind.
|
||||
// Callers supply concrete values so every branch is reachable from tests
|
||||
// without touching debug.ReadBuildInfo or the extension registries.
|
||||
//
|
||||
// Priority order mirrors the design doc:
|
||||
// 1. no build info → unknown
|
||||
// 2. main module path not the official one → extended (ISV wrapper)
|
||||
// 3. any non-builtin provider (credential / transport / fileio) → extended
|
||||
// 4. otherwise → official
|
||||
func classifyBuild(mainPath string, haveBuildInfo bool, credProviders []any, transportProvider, fileioProvider any) string {
|
||||
if !haveBuildInfo {
|
||||
return BuildKindUnknown
|
||||
}
|
||||
if mainPath != "" && mainPath != officialModulePath {
|
||||
return BuildKindExtended
|
||||
}
|
||||
for _, p := range credProviders {
|
||||
if !isBuiltinProvider(p) {
|
||||
return BuildKindExtended
|
||||
}
|
||||
}
|
||||
if transportProvider != nil && !isBuiltinProvider(transportProvider) {
|
||||
return BuildKindExtended
|
||||
}
|
||||
if fileioProvider != nil && !isBuiltinProvider(fileioProvider) {
|
||||
return BuildKindExtended
|
||||
}
|
||||
return BuildKindOfficial
|
||||
}
|
||||
|
||||
// isBuiltinProvider reports whether p is declared under the official module
|
||||
// path. Third-party providers live under their own module and fail this check.
|
||||
// Using reflect.PkgPath makes this robust against Name() spoofing since
|
||||
// package paths are fixed at compile time.
|
||||
func isBuiltinProvider(p any) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
t := reflect.TypeOf(p)
|
||||
if t == nil {
|
||||
return false
|
||||
}
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
pkg := t.PkgPath()
|
||||
return pkg == officialModulePath || strings.HasPrefix(pkg, officialModulePath+"/")
|
||||
}
|
||||
|
||||
// ── Context utilities ──
|
||||
|
||||
type ctxKey string
|
||||
|
||||
34
internal/cmdutil/secheader_sidecar_test.go
Normal file
34
internal/cmdutil/secheader_sidecar_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
sidecarcred "github.com/larksuite/cli/extension/credential/sidecar"
|
||||
sidecartrans "github.com/larksuite/cli/extension/transport/sidecar"
|
||||
)
|
||||
|
||||
// TestIsBuiltinProvider_SidecarProviders locks the classification for the
|
||||
// sidecar-mode providers enumerated in design doc §3.3.2 as "官方自带". These
|
||||
// types only compile when the `authsidecar` build tag is active, so the test
|
||||
// is guarded by the same tag.
|
||||
func TestIsBuiltinProvider_SidecarProviders(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
provider any
|
||||
}{
|
||||
{"sidecar credential provider", &sidecarcred.Provider{}},
|
||||
{"sidecar transport provider", &sidecartrans.Provider{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if !isBuiltinProvider(tc.provider) {
|
||||
t.Fatalf("%T must be classified as builtin (PkgPath under %s)", tc.provider, officialModulePath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
262
internal/cmdutil/secheader_test.go
Normal file
262
internal/cmdutil/secheader_test.go
Normal file
@@ -0,0 +1,262 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
envcred "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isBuiltinProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// cmdutilLocalProvider has PkgPath under the official module
|
||||
// ("github.com/larksuite/cli/internal/cmdutil") and should be classified
|
||||
// as builtin.
|
||||
type cmdutilLocalProvider struct{}
|
||||
|
||||
// Name intentionally returns a value that mimics an external provider; the
|
||||
// PkgPath-based classifier must ignore it. See TestIsBuiltinProvider_PkgPathNotSpoofableByName.
|
||||
func (cmdutilLocalProvider) Name() string { return "external-spoofed-provider" }
|
||||
func (cmdutilLocalProvider) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (cmdutilLocalProvider) ResolveToken(context.Context, credential.TokenSpec) (*credential.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestIsBuiltinProvider_Nil(t *testing.T) {
|
||||
if isBuiltinProvider(nil) {
|
||||
t.Fatal("isBuiltinProvider(nil) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBuiltinProvider_TypeUnderOfficialModule(t *testing.T) {
|
||||
if !isBuiltinProvider(&cmdutilLocalProvider{}) {
|
||||
t.Fatal("type under github.com/larksuite/cli/... should be builtin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBuiltinProvider_StdlibTypeIsNotBuiltin(t *testing.T) {
|
||||
// A standard library type has PkgPath "net/http" — outside official module.
|
||||
// This covers the non-builtin branch, which we cannot trigger from inside
|
||||
// this test file using a locally-defined type.
|
||||
if isBuiltinProvider(&http.Server{}) {
|
||||
t.Fatal("stdlib type classified as builtin, PkgPath check is broken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBuiltinProvider_PkgPathNotSpoofableByName(t *testing.T) {
|
||||
// Name() returns a string, but classification uses reflect.Type.PkgPath
|
||||
// which is compile-time fixed. The local type returns a name that looks
|
||||
// like an ISV provider; it must still classify as builtin.
|
||||
p := &cmdutilLocalProvider{}
|
||||
if p.Name() != "external-spoofed-provider" {
|
||||
t.Fatalf("sanity check: Name() = %q, spoof value lost", p.Name())
|
||||
}
|
||||
if !isBuiltinProvider(p) {
|
||||
t.Fatal("isBuiltinProvider should decide by PkgPath, not Name()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsBuiltinProvider_NonPointerValues covers the non-pointer reflect branch.
|
||||
// The existing tests only exercise pointer receivers (&T{}); when a provider
|
||||
// is passed by value the reflect.Kind is not Ptr and t.Elem() is skipped.
|
||||
func TestIsBuiltinProvider_NonPointerValues(t *testing.T) {
|
||||
if !isBuiltinProvider(cmdutilLocalProvider{}) {
|
||||
t.Fatal("non-pointer local type should be builtin (PkgPath still under official module)")
|
||||
}
|
||||
// http.Server as a non-pointer — PkgPath "net/http", not under official.
|
||||
if isBuiltinProvider(http.Server{}) {
|
||||
t.Fatal("non-pointer stdlib type should not be builtin")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsBuiltinProvider_RealBuiltinProviders locks down the classification
|
||||
// for the concrete providers enumerated in design doc §3.3.2 as "官方自带":
|
||||
// env credential provider and local fileio provider. If any of these is
|
||||
// moved out of the official module tree in the future, this test must flip
|
||||
// red so the new package path is explicitly considered.
|
||||
//
|
||||
// The sidecar providers (extension/credential/sidecar and
|
||||
// extension/transport/sidecar) are guarded by the `authsidecar` build tag
|
||||
// and covered in secheader_sidecar_test.go under that tag.
|
||||
func TestIsBuiltinProvider_RealBuiltinProviders(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
provider any
|
||||
}{
|
||||
{"env credential provider", &envcred.Provider{}},
|
||||
{"local fileio provider", &localfileio.Provider{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if !isBuiltinProvider(tc.provider) {
|
||||
t.Fatalf("%T must be classified as builtin (PkgPath under %s)", tc.provider, officialModulePath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// computeBuildKind
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestComputeBuildKind_ReturnsKnownValue(t *testing.T) {
|
||||
// Under `go test`, Main.Path is typically the module being tested
|
||||
// ("github.com/larksuite/cli"); the concrete return may still be
|
||||
// official, extended, or unknown depending on Main.Path and the
|
||||
// registered providers. Just assert it's one of the defined values.
|
||||
got := computeBuildKind()
|
||||
switch got {
|
||||
case BuildKindOfficial, BuildKindExtended, BuildKindUnknown:
|
||||
default:
|
||||
t.Fatalf("computeBuildKind() = %q, want one of official/extended/unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// classifyBuild — pure branching logic
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// These tests cover every branch of classifyBuild with explicit inputs,
|
||||
// which is impossible from computeBuildKind alone because debug.ReadBuildInfo
|
||||
// and the process-wide provider registries can't be reshaped in a test.
|
||||
|
||||
func TestClassifyBuild_NoBuildInfo_ReturnsUnknown(t *testing.T) {
|
||||
if got := classifyBuild("", false, nil, nil, nil); got != BuildKindUnknown {
|
||||
t.Fatalf("classifyBuild(haveBuildInfo=false) = %q, want %q", got, BuildKindUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_ExtendedMainPath_ReturnsExtended(t *testing.T) {
|
||||
cases := []string{
|
||||
"github.com/acme/lark-cli-wrapper",
|
||||
"example.com/isv/lark",
|
||||
"gitlab.mycorp.internal/tools/lark-cli-fork",
|
||||
}
|
||||
for _, mp := range cases {
|
||||
t.Run(mp, func(t *testing.T) {
|
||||
if got := classifyBuild(mp, true, nil, nil, nil); got != BuildKindExtended {
|
||||
t.Fatalf("mainPath=%q classifyBuild = %q, want %q", mp, got, BuildKindExtended)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_OfficialMainPath_NoProviders_ReturnsOfficial(t *testing.T) {
|
||||
if got := classifyBuild(officialModulePath, true, nil, nil, nil); got != BuildKindOfficial {
|
||||
t.Fatalf("classifyBuild(official, no providers) = %q, want %q", got, BuildKindOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_EmptyMainPath_DoesNotTriggerExtended(t *testing.T) {
|
||||
// An empty Main.Path (rare, e.g. `go run` pre-1.18) must not be treated
|
||||
// as extended by itself — the classifier falls through to provider checks.
|
||||
if got := classifyBuild("", true, nil, nil, nil); got != BuildKindOfficial {
|
||||
t.Fatalf("classifyBuild(empty mainPath, no providers) = %q, want %q", got, BuildKindOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_NonBuiltinCredentialProvider_ReturnsExtended(t *testing.T) {
|
||||
// Any non-builtin credential provider flips the verdict to extended.
|
||||
got := classifyBuild(officialModulePath, true, []any{&http.Server{}}, nil, nil)
|
||||
if got != BuildKindExtended {
|
||||
t.Fatalf("classifyBuild with external credential = %q, want %q", got, BuildKindExtended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_MixedCredentialProviders_ExtendedWins(t *testing.T) {
|
||||
// Even if most providers are builtin, a single external one decides.
|
||||
providers := []any{&cmdutilLocalProvider{}, &http.Server{}}
|
||||
if got := classifyBuild(officialModulePath, true, providers, nil, nil); got != BuildKindExtended {
|
||||
t.Fatalf("classifyBuild mixed providers = %q, want %q", got, BuildKindExtended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_NonBuiltinTransportProvider_ReturnsExtended(t *testing.T) {
|
||||
got := classifyBuild(officialModulePath, true, nil, &http.Server{}, nil)
|
||||
if got != BuildKindExtended {
|
||||
t.Fatalf("classifyBuild with external transport = %q, want %q", got, BuildKindExtended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_NonBuiltinFileioProvider_ReturnsExtended(t *testing.T) {
|
||||
got := classifyBuild(officialModulePath, true, nil, nil, &http.Server{})
|
||||
if got != BuildKindExtended {
|
||||
t.Fatalf("classifyBuild with external fileio = %q, want %q", got, BuildKindExtended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyBuild_AllBuiltinProviders_ReturnsOfficial(t *testing.T) {
|
||||
// All three slots filled with builtin providers must still classify as official.
|
||||
got := classifyBuild(
|
||||
officialModulePath, true,
|
||||
[]any{&cmdutilLocalProvider{}},
|
||||
&cmdutilLocalProvider{},
|
||||
&cmdutilLocalProvider{},
|
||||
)
|
||||
if got != BuildKindOfficial {
|
||||
t.Fatalf("classifyBuild all-builtin = %q, want %q", got, BuildKindOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyBuild_MainPathPriorityOverProviders documents that the main
|
||||
// module path takes precedence: even with only builtin providers, a non-
|
||||
// official main path still yields extended.
|
||||
func TestClassifyBuild_MainPathPriorityOverProviders(t *testing.T) {
|
||||
got := classifyBuild(
|
||||
"github.com/acme/lark-wrapper", true,
|
||||
[]any{&cmdutilLocalProvider{}},
|
||||
&cmdutilLocalProvider{},
|
||||
&cmdutilLocalProvider{},
|
||||
)
|
||||
if got != BuildKindExtended {
|
||||
t.Fatalf("main-path override failed: got %q, want %q", got, BuildKindExtended)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DetectBuildKind — sync.Once caching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetectBuildKind_StableAcrossCalls(t *testing.T) {
|
||||
a := DetectBuildKind()
|
||||
b := DetectBuildKind()
|
||||
if a != b {
|
||||
t.Fatalf("DetectBuildKind() returned different values on repeat: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BaseSecurityHeaders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesBuildHeader(t *testing.T) {
|
||||
h := BaseSecurityHeaders()
|
||||
v := h.Get(HeaderBuild)
|
||||
if v == "" {
|
||||
t.Fatal("BaseSecurityHeaders missing X-Cli-Build header")
|
||||
}
|
||||
switch v {
|
||||
case BuildKindOfficial, BuildKindExtended, BuildKindUnknown:
|
||||
default:
|
||||
t.Fatalf("X-Cli-Build = %q, want one of official/extended/unknown", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
h := BaseSecurityHeaders()
|
||||
for _, key := range []string{HeaderSource, HeaderVersion, HeaderBuild, HeaderUserAgent} {
|
||||
if h.Get(key) == "" {
|
||||
t.Errorf("BaseSecurityHeaders missing %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,24 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
return util.FallbackTransport().RoundTrip(req)
|
||||
}
|
||||
|
||||
// BuildHeaderTransport is an http.RoundTripper that force-writes the
|
||||
// X-Cli-Build header before every request. Used in the SDK transport chain,
|
||||
// where SecurityHeaderTransport is not installed, to prevent extensions from
|
||||
// tampering with the build classification. The direct HTTP chain is already
|
||||
// covered by SecurityHeaderTransport iterating BaseSecurityHeaders.
|
||||
type BuildHeaderTransport struct {
|
||||
Base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(HeaderBuild, DetectBuildKind())
|
||||
if t.Base != nil {
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
return util.FallbackTransport().RoundTrip(req)
|
||||
}
|
||||
|
||||
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
|
||||
// headers into every request. Shortcut headers are read from the request context.
|
||||
type SecurityHeaderTransport struct {
|
||||
|
||||
@@ -97,13 +97,18 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
|
||||
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := buildSDKTransport()
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
@@ -116,7 +121,7 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
|
||||
transport := buildSDKTransport()
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → UserAgent → Retry → Base
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
mid, ok := transport.(*extensionMiddleware)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||
@@ -125,9 +130,13 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
|
||||
@@ -139,13 +148,18 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
|
||||
transport := buildSDKTransport()
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
@@ -236,6 +250,115 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// buildTamperingInterceptor tries to delete and spoof X-Cli-Build via
|
||||
// PreRoundTrip. The SDK chain's BuildHeaderTransport must restore the real
|
||||
// value before the request leaves the process.
|
||||
type buildTamperingInterceptor struct{}
|
||||
|
||||
func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Del(HeaderBuild)
|
||||
req.Header.Set(HeaderBuild, "ext-tampered-build")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
|
||||
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
|
||||
// transport chain, even when an extension tries to delete or spoof it. This
|
||||
// closes the gap where the SDK chain had no equivalent of
|
||||
// SecurityHeaderTransport (see design doc §3.3.3).
|
||||
func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
var receivedBuild string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedBuild = r.Header.Get(HeaderBuild)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Replicate the SDK chain layering used by buildSDKTransport.
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &UserAgentTransport{Base: base}
|
||||
base = &BuildHeaderTransport{Base: base}
|
||||
transport := wrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if receivedBuild == "ext-tampered-build" {
|
||||
t.Fatalf("%s = %q, extension tampering leaked to network", HeaderBuild, receivedBuild)
|
||||
}
|
||||
want := DetectBuildKind()
|
||||
if receivedBuild != want {
|
||||
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_OverridesEvenWithoutTamper verifies that even if
|
||||
// no extension is registered, BuildHeaderTransport writes X-Cli-Build.
|
||||
func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
|
||||
var receivedBuild string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedBuild = r.Header.Get(HeaderBuild)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
transport := &BuildHeaderTransport{Base: http.DefaultTransport}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if receivedBuild == "" {
|
||||
t.Fatalf("%s header missing, BuildHeaderTransport did not inject", HeaderBuild)
|
||||
}
|
||||
want := DetectBuildKind()
|
||||
if receivedBuild != want {
|
||||
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
|
||||
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
|
||||
var receivedBuild string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedBuild = r.Header.Get(HeaderBuild)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
transport := &BuildHeaderTransport{Base: nil}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request via nil-Base transport failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
want := DetectBuildKind()
|
||||
if receivedBuild != want {
|
||||
t.Fatalf("%s = %q, want %q (header must be set even on nil-Base path)",
|
||||
HeaderBuild, receivedBuild, want)
|
||||
}
|
||||
}
|
||||
|
||||
// interceptorFunc adapts a function to exttransport.Interceptor.
|
||||
type interceptorFunc func(*http.Request) func(*http.Response, error)
|
||||
|
||||
|
||||
@@ -41,6 +41,14 @@ const (
|
||||
|
||||
// Sheets float image: width/height/offset out of range or invalid.
|
||||
LarkErrSheetsFloatImageInvalidDims = 1310246
|
||||
|
||||
// Drive permission apply: per-user-per-document submission limit (5/day) reached.
|
||||
LarkErrDrivePermApplyRateLimit = 1063006
|
||||
// Drive permission apply: request is not applicable for this document
|
||||
// (e.g. the document is configured to disallow access requests, or the
|
||||
// caller already holds the requested permission, or the target type does
|
||||
// not accept apply operations).
|
||||
LarkErrDrivePermApplyNotApplicable = 1063007
|
||||
)
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
|
||||
@@ -82,6 +90,14 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
return ExitAPI, "invalid_params",
|
||||
"check --width / --height / --offset-x / --offset-y: " +
|
||||
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
|
||||
|
||||
// drive permission-apply specific guidance
|
||||
case LarkErrDrivePermApplyRateLimit:
|
||||
return ExitAPI, "rate_limit",
|
||||
"permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly"
|
||||
case LarkErrDrivePermApplyNotApplicable:
|
||||
return ExitAPI, "invalid_params",
|
||||
"this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly"
|
||||
}
|
||||
|
||||
return ExitAPI, "api_error", ""
|
||||
|
||||
@@ -47,6 +47,20 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
wantType: "invalid_params",
|
||||
wantHint: "--width / --height / --offset-x / --offset-y",
|
||||
},
|
||||
{
|
||||
name: "drive permission apply rate limit",
|
||||
code: LarkErrDrivePermApplyRateLimit,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "rate_limit",
|
||||
wantHint: "5 times per day",
|
||||
},
|
||||
{
|
||||
name: "drive permission apply not applicable",
|
||||
code: LarkErrDrivePermApplyNotApplicable,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "invalid_params",
|
||||
wantHint: "does not accept a permission-apply request",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.16",
|
||||
"version": "1.0.17",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -67,11 +67,15 @@ func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
stdout.Reset()
|
||||
if stderr, ok := factory.IOStreams.ErrOut.(*bytes.Buffer); ok {
|
||||
stderr.Reset()
|
||||
}
|
||||
return parent.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
||||
@@ -96,6 +100,9 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
if data["created"] != true {
|
||||
t.Fatalf("created = %#v, want true", data["created"])
|
||||
}
|
||||
if !strings.Contains(stderr.String(), baseCreateHint) {
|
||||
t.Fatalf("stderr = %q, want %q", stderr.String(), baseCreateHint)
|
||||
}
|
||||
base, _ := data["base"].(map[string]interface{})
|
||||
if got := common.GetString(base, "app_token"); got != "app_x" {
|
||||
t.Fatalf("base.app_token = %q, want %q", got, "app_x")
|
||||
@@ -184,6 +191,7 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
|
||||
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
@@ -198,6 +206,9 @@ func TestBaseWorkspaceExecuteCreateBotAutoGrantSkippedWithoutCurrentUser(t *test
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if !strings.Contains(stderr.String(), baseCreateHint) {
|
||||
t.Fatalf("stderr = %q, want %q", stderr.String(), baseCreateHint)
|
||||
}
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
|
||||
@@ -5,11 +5,14 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const baseCreateHint = "Tip: New bases include a default empty table with 5-10 blank records. After finishing table/field setup on this base, ask whether to delete that default table. If yes, run +table-list first, then delete the default table."
|
||||
|
||||
func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token").
|
||||
@@ -65,6 +68,7 @@ func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
out := map[string]interface{}{"base": data, "created": true}
|
||||
augmentBasePermissionGrant(runtime, out, data)
|
||||
runtime.Out(out, nil)
|
||||
fmt.Fprintln(runtime.IO().ErrOut, baseCreateHint)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestShortcutsCatalog(t *testing.T) {
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-upload-attachment", "+record-delete",
|
||||
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-delete",
|
||||
"+record-history-list",
|
||||
"+base-get", "+base-copy", "+base-create",
|
||||
"+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable",
|
||||
|
||||
@@ -112,6 +112,56 @@ func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext)
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func dryRunRecordShareBatch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
recordIDs := deduplicateRecordIDs(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/share_links/batch").
|
||||
Body(map[string]interface{}{"record_ids": recordIDs}).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
const maxShareBatchSize = 100
|
||||
|
||||
func validateRecordShareBatch(runtime *common.RuntimeContext) error {
|
||||
recordIDs := deduplicateRecordIDs(runtime)
|
||||
if len(recordIDs) == 0 {
|
||||
return common.FlagErrorf("--record-ids is required and must not be empty")
|
||||
}
|
||||
if len(recordIDs) > maxShareBatchSize {
|
||||
return common.FlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deduplicateRecordIDs(runtime *common.RuntimeContext) []string {
|
||||
raw := runtime.StrSlice("record-ids")
|
||||
seen := make(map[string]bool, len(raw))
|
||||
result := make([]string, 0, len(raw))
|
||||
for _, id := range raw {
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
result = append(result, id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func executeRecordShareBatch(runtime *common.RuntimeContext) error {
|
||||
recordIDs := deduplicateRecordIDs(runtime)
|
||||
body := map[string]interface{}{
|
||||
"record_ids": recordIDs,
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "share_links", "batch"),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
|
||||
35
shortcuts/base/record_share_link_create.go
Normal file
35
shortcuts/base/record_share_link_create.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseRecordShareLinkCreate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-share-link-create",
|
||||
Description: "Generate share links for one or more records (max 100 per request)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:record:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Single record: --base-token xxx --table-id tblxxx --record-ids recxxx`,
|
||||
`Multiple records: --base-token xxx --table-id tblxxx --record-ids rec001,rec002,rec003`,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordShareBatch(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordShareBatch,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordShareBatch(runtime)
|
||||
},
|
||||
}
|
||||
@@ -42,6 +42,7 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseRecordUpsert,
|
||||
BaseRecordBatchCreate,
|
||||
BaseRecordBatchUpdate,
|
||||
BaseRecordShareLinkCreate,
|
||||
BaseRecordUploadAttachment,
|
||||
BaseRecordDelete,
|
||||
BaseRecordHistoryList,
|
||||
|
||||
@@ -181,6 +181,12 @@ func (ctx *RuntimeContext) StrArray(name string) []string {
|
||||
return v
|
||||
}
|
||||
|
||||
// StrSlice returns a string-slice flag value (supports CSV splitting and repeated flags).
|
||||
func (ctx *RuntimeContext) StrSlice(name string) []string {
|
||||
v, _ := ctx.Cmd.Flags().GetStringSlice(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// ── API helpers ──
|
||||
|
||||
// CallAPI uses an internal HTTP wrapper with limited control over request/response.
|
||||
@@ -857,6 +863,8 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmd.Flags().Int(fl.Name, d, desc)
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(fl.Name, nil, desc)
|
||||
case "string_slice":
|
||||
cmd.Flags().StringSlice(fl.Name, nil, desc)
|
||||
default:
|
||||
cmd.Flags().String(fl.Name, fl.Default, desc)
|
||||
}
|
||||
@@ -868,7 +876,7 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
}
|
||||
if len(fl.Enum) > 0 {
|
||||
vals := fl.Enum
|
||||
_ = cmd.RegisterFlagCompletionFunc(fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return vals, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
@@ -884,7 +892,7 @@ 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 {
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
|
||||
98
shortcuts/common/runner_flag_completion_test.go
Normal file
98
shortcuts/common/runner_flag_completion_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// 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_FlagCompletionsRegistered exercises the two
|
||||
// cmdutil.RegisterFlagCompletion call sites in registerShortcutFlagsWithContext:
|
||||
// the per-flag enum completion (runner.go:879) and the auto-injected --format
|
||||
// completion (runner.go:895).
|
||||
func TestShortcutMount_FlagCompletionsRegistered(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
cmdutil.SetFlagCompletionsDisabled(false)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+fetch",
|
||||
Description: "fetch doc",
|
||||
HasFormat: true,
|
||||
Flags: []Flag{
|
||||
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+fetch"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
|
||||
// Enum flag completion.
|
||||
fn, ok := cmd.GetFlagCompletionFunc("sort-by")
|
||||
if !ok {
|
||||
t.Fatal("expected completion func for --sort-by")
|
||||
}
|
||||
got, _ := fn(cmd, nil, "")
|
||||
if len(got) != 2 || got[0] != "asc" || got[1] != "desc" {
|
||||
t.Fatalf("sort-by completion = %v, want [asc desc]", got)
|
||||
}
|
||||
|
||||
// HasFormat-injected --format completion.
|
||||
fn, ok = cmd.GetFlagCompletionFunc("format")
|
||||
if !ok {
|
||||
t.Fatal("expected completion func for --format")
|
||||
}
|
||||
got, _ = fn(cmd, nil, "")
|
||||
want := []string{"json", "pretty", "table", "ndjson", "csv"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("format completion = %v, want %v", got, want)
|
||||
}
|
||||
for i, v := range want {
|
||||
if got[i] != v {
|
||||
t.Fatalf("format completion[%d] = %q, want %q", i, got[i], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutMount_FlagCompletionsDisabled verifies the switch actually
|
||||
// prevents the two registrations from landing in cobra's global map.
|
||||
func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
cmdutil.SetFlagCompletionsDisabled(true)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+fetch",
|
||||
Description: "fetch doc",
|
||||
HasFormat: true,
|
||||
Flags: []Flag{
|
||||
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+fetch"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if _, ok := cmd.GetFlagCompletionFunc("sort-by"); ok {
|
||||
t.Fatal("did not expect completion func for --sort-by when disabled")
|
||||
}
|
||||
if _, ok := cmd.GetFlagCompletionFunc("format"); ok {
|
||||
t.Fatal("did not expect completion func for --format when disabled")
|
||||
}
|
||||
}
|
||||
150
shortcuts/drive/drive_apply_permission.go
Normal file
150
shortcuts/drive/drive_apply_permission.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// permApplyTypes is the authoritative list of type values the apply-permission
|
||||
// endpoint accepts for its required `type` query parameter.
|
||||
var permApplyTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "slides",
|
||||
}
|
||||
|
||||
// permApplyURLMarkers maps document URL path markers to the `type` value the
|
||||
// apply-permission endpoint expects. Markers are disjoint strings (each begins
|
||||
// with "/" and ends with "/"), so a simple substring scan disambiguates them.
|
||||
var permApplyURLMarkers = []struct {
|
||||
Marker string
|
||||
Type string
|
||||
}{
|
||||
{"/wiki/", "wiki"},
|
||||
{"/docx/", "docx"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnote/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/doc/", "doc"},
|
||||
}
|
||||
|
||||
// resolvePermApplyTarget extracts (token, type) from a user-supplied --token
|
||||
// value that may be either a bare token or a full document URL, plus an
|
||||
// optional explicit --type. Explicit --type wins over URL inference.
|
||||
func resolvePermApplyTarget(raw, explicitType string) (token, docType string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", output.ErrValidation("--token is required")
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
for _, m := range permApplyURLMarkers {
|
||||
if tok, ok := extractURLToken(raw, m.Marker); ok {
|
||||
token = tok
|
||||
if explicitType == "" {
|
||||
docType = m.Type
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", "", output.ErrValidation(
|
||||
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
|
||||
raw,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
token = raw
|
||||
}
|
||||
|
||||
if explicitType != "" {
|
||||
docType = explicitType
|
||||
}
|
||||
if docType == "" {
|
||||
return "", "", output.ErrValidation(
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(permApplyTypes, ", "),
|
||||
)
|
||||
}
|
||||
return token, docType, nil
|
||||
}
|
||||
|
||||
// DriveApplyPermission applies to the document owner for view or edit access
|
||||
// on behalf of the invoking user. Matches the open-apis endpoint
|
||||
// /open-apis/drive/v1/permissions/:token/members/apply.
|
||||
//
|
||||
// The backend accepts only user_access_token for this endpoint, so the
|
||||
// shortcut declares AuthTypes: ["user"] — bot identity is rejected up-front.
|
||||
var DriveApplyPermission = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+apply-permission",
|
||||
Description: "Apply to the document owner for view or edit permission on a doc/sheet/file/wiki/bitable/docx/mindnote/slides",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:permission.member:apply"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: permApplyTypes},
|
||||
{Name: "perm", Desc: "permission to request", Required: true, Enum: []string{"view", "edit"}},
|
||||
{Name: "remark", Desc: "optional note shown on the request card sent to the owner"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, _, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, docType, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
body := buildPermApplyBody(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Apply to document owner for access").
|
||||
POST("/open-apis/drive/v1/permissions/:token/members/apply").
|
||||
Params(map[string]interface{}{"type": docType}).
|
||||
Body(body).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, docType, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := buildPermApplyBody(runtime)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Requesting %s access on %s %s...\n",
|
||||
runtime.Str("perm"), docType, common.MaskToken(token))
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/apply", validate.EncodePathSegment(token)),
|
||||
map[string]interface{}{"type": docType},
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildPermApplyBody returns the request body with the caller-supplied perm
|
||||
// and optional remark. remark is omitted entirely when empty so the server
|
||||
// doesn't render an empty note on the request card.
|
||||
func buildPermApplyBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{"perm": runtime.Str("perm")}
|
||||
if s := runtime.Str("remark"); s != "" {
|
||||
body["remark"] = s
|
||||
}
|
||||
return body
|
||||
}
|
||||
238
shortcuts/drive/drive_apply_permission_test.go
Normal file
238
shortcuts/drive/drive_apply_permission_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// ── resolvePermApplyTarget unit tests ────────────────────────────────────────
|
||||
|
||||
func TestResolvePermApplyTarget_BareTokenNeedsType(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := resolvePermApplyTarget("bareToken", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "--type is required") {
|
||||
t.Fatalf("expected --type required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_BareTokenWithType(t *testing.T) {
|
||||
t.Parallel()
|
||||
token, docType, err := resolvePermApplyTarget("bareToken", "docx")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "bareToken" || docType != "docx" {
|
||||
t.Fatalf("got token=%q type=%q, want bareToken/docx", token, docType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_URLInference(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{"docx", "https://example.feishu.cn/docx/doxTok123?from=share", "doxTok123", "docx"},
|
||||
{"sheets", "https://example.feishu.cn/sheets/shtTok456?sheet=abc", "shtTok456", "sheet"},
|
||||
{"base", "https://example.feishu.cn/base/bscTok789", "bscTok789", "bitable"},
|
||||
{"bitable", "https://example.feishu.cn/bitable/bscTok789", "bscTok789", "bitable"},
|
||||
{"file", "https://example.feishu.cn/file/boxTok111", "boxTok111", "file"},
|
||||
{"wiki", "https://example.feishu.cn/wiki/wikTok222", "wikTok222", "wiki"},
|
||||
{"legacy doc", "https://example.feishu.cn/doc/docTok333", "docTok333", "doc"},
|
||||
{"mindnote", "https://example.feishu.cn/mindnote/mnTok444", "mnTok444", "mindnote"},
|
||||
{"slides", "https://example.feishu.cn/slides/slTok666", "slTok666", "slides"},
|
||||
}
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
token, docType, err := resolvePermApplyTarget(tt.raw, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != tt.wantTok || docType != tt.wantType {
|
||||
t.Fatalf("got (%q,%q), want (%q,%q)", token, docType, tt.wantTok, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_ExplicitTypeOverridesURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Even though the URL marker is /docx/, an explicit --type wins.
|
||||
token, docType, err := resolvePermApplyTarget("https://example.feishu.cn/docx/doxTok123", "wiki")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "doxTok123" || docType != "wiki" {
|
||||
t.Fatalf("got (%q,%q), want (doxTok123,wiki)", token, docType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_UnrecognizedURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := resolvePermApplyTarget("https://example.feishu.cn/unknown/xyz", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "could not infer token") {
|
||||
t.Fatalf("expected infer error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := resolvePermApplyTarget(" ", "docx")
|
||||
if err == nil || !strings.Contains(err.Error(), "--token is required") {
|
||||
t.Fatalf("expected token required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── shortcut integration tests ──────────────────────────────────────────────
|
||||
|
||||
func TestDriveApplyPermission_ValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveApplyPermission, []string{
|
||||
"+apply-permission", "--perm", "view", "--type", "docx", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveApplyPermission_ValidateRejectsBadPerm(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveApplyPermission, []string{
|
||||
"+apply-permission",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--perm", "full_access",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--perm") {
|
||||
t.Fatalf("expected perm enum error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveApplyPermission_DryRunInfersTypeFromURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveApplyPermission, []string{
|
||||
"+apply-permission",
|
||||
"--token", "https://example.feishu.cn/sheets/shtTok?sheet=abc",
|
||||
"--perm", "edit",
|
||||
"--remark", "please",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/drive/v1/permissions/shtTok/members/apply",
|
||||
`"POST"`,
|
||||
`"sheet"`,
|
||||
`"edit"`,
|
||||
`"please"`,
|
||||
`"shtTok"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveApplyPermission_ExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
// Stub URL includes "?type=docx" — the stub only matches when the request
|
||||
// URL contains that query, so this doubles as an assertion that the
|
||||
// shortcut emits the type query parameter.
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/doxTok123/members/apply?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"applied": true},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveApplyPermission, []string{
|
||||
"+apply-permission",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--perm", "view",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if body["perm"] != "view" {
|
||||
t.Fatalf("perm = %v, want view", body["perm"])
|
||||
}
|
||||
if _, hasRemark := body["remark"]; hasRemark {
|
||||
t.Fatalf("remark should be omitted when empty, got: %v", body["remark"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveApplyPermission_ExecuteNotApplicableHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/doxTok/members/apply",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1063007, "msg": "request not applicable",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveApplyPermission, []string{
|
||||
"+apply-permission",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--perm", "view",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 1063007")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not applicable") {
|
||||
t.Fatalf("expected surfaced server message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveApplyPermission_ExecuteRateLimitHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/doxTok/members/apply",
|
||||
Status: 429,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1063006, "msg": "quota exceeded",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveApplyPermission, []string{
|
||||
"+apply-permission",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--perm", "view",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 1063006")
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,6 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveMove,
|
||||
DriveDelete,
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+move",
|
||||
"+delete",
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
}
|
||||
|
||||
if len(got) != len(want) {
|
||||
|
||||
@@ -262,7 +262,7 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) {
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
target := filepath.Join("nested", "resource.bin")
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "file_123", "file", target)
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "file_123", "file", target, true)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
@@ -308,7 +308,7 @@ func TestDownloadIMResourceToPathImageUsesSingleRequestWithoutRange(t *testing.T
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
gotPath, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_img", "img_123", "image", "image")
|
||||
gotPath, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_img", "img_123", "image", "image", true)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
@@ -342,7 +342,7 @@ func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) {
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_403", "file_403", "file", "out.bin")
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_403", "file_403", "file", "out.bin", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 403: denied") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
@@ -372,7 +372,7 @@ func TestDownloadIMResourceToPathRetriesNetworkError(t *testing.T) {
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := "out.bin"
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry", "file_retry", "file", target)
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry", "file_retry", "file", target, true)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
@@ -408,7 +408,7 @@ func TestDownloadIMResourceToPathRetrySecondAttemptSuccess(t *testing.T) {
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := "out.bin"
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry2", "file_retry2", "file", target)
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry2", "file_retry2", "file", target, true)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
@@ -444,7 +444,7 @@ func TestDownloadIMResourceToPathRetryContextCanceled(t *testing.T) {
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := "out.bin"
|
||||
_, _, err := downloadIMResourceToPath(ctx, runtime, "om_cancel", "file_cancel", "file", target)
|
||||
_, _, err := downloadIMResourceToPath(ctx, runtime, "om_cancel", "file_cancel", "file", target, true)
|
||||
if err != context.Canceled {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v, want context.Canceled", err)
|
||||
}
|
||||
@@ -525,7 +525,7 @@ func TestDownloadIMResourceToPathRangeDownload(t *testing.T) {
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := filepath.Join("nested", "resource.bin")
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_range", "file_range", "file", target)
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_range", "file_range", "file", target, true)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
@@ -567,7 +567,7 @@ func TestDownloadIMResourceToPathInvalidContentRange(t *testing.T) {
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_bad", "file_bad", "file", "out.bin")
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_bad", "file_bad", "file", "out.bin", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid Content-Range header") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
@@ -596,7 +596,7 @@ func TestDownloadIMResourceToPathRangeChunkFailureCleansOutput(t *testing.T) {
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
target := "out.bin"
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_miderr", "file_miderr", "file", target)
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_miderr", "file_miderr", "file", target, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 500: chunk failed") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
@@ -622,7 +622,7 @@ func TestDownloadIMResourceToPathRangeOverflowCleansOutput(t *testing.T) {
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
target := "out.bin"
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_overflow", "file_overflow", "file", target)
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_overflow", "file_overflow", "file", target, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "chunk overflow") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
@@ -658,7 +658,7 @@ func TestDownloadIMResourceToPathRangeShortChunkSizeMismatch(t *testing.T) {
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_short", "file_short", "file", "out.bin")
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_short", "file_short", "file", "out.bin", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "file size mismatch") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -593,7 +593,7 @@ func TestDownloadIMResourceToPathHTTPClientError(t *testing.T) {
|
||||
return nil, errors.New("http client unavailable")
|
||||
}))
|
||||
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "img_123", "image", "out.bin")
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "img_123", "image", "out.bin", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "http client unavailable") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
@@ -637,6 +637,68 @@ func TestParseTotalSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseContentDispositionFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{name: "empty header", header: "", want: ""},
|
||||
{name: "no filename param", header: "attachment", want: ""},
|
||||
{name: "plain filename", header: `attachment; filename="report.xlsx"`, want: "report.xlsx"},
|
||||
{name: "unquoted filename", header: `attachment; filename=report.xlsx`, want: "report.xlsx"},
|
||||
{name: "RFC 5987 UTF-8 encoded", header: `attachment; filename*=UTF-8''%E5%AD%A3%E5%BA%A6%E6%8A%A5%E5%91%8A.xlsx`, want: "季度报告.xlsx"},
|
||||
{name: "RFC 5987 takes priority over plain", header: `attachment; filename="fallback.xlsx"; filename*=UTF-8''%E5%AD%A3%E5%BA%A6%E6%8A%A5%E5%91%8A.xlsx`, want: "季度报告.xlsx"},
|
||||
{name: "path traversal stripped", header: `attachment; filename="../../etc/passwd"`, want: "passwd"},
|
||||
{name: "windows path stripped", header: `attachment; filename="C:\\Windows\\evil.exe"`, want: "evil.exe"},
|
||||
{name: "control char rejected", header: "attachment; filename=\"evil\x01file.txt\"", want: ""},
|
||||
{name: "malformed header", header: "not/valid/mime; ===", want: ""},
|
||||
{name: "whitespace trimmed", header: `attachment; filename=" report.pdf "`, want: "report.pdf"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := parseContentDispositionFilename(tt.header); got != tt.want {
|
||||
t.Fatalf("parseContentDispositionFilename(%q) = %q, want %q", tt.header, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIMResourceDownloadPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
safePath string
|
||||
contentType string
|
||||
contentDisposition string
|
||||
userSpecifiedOutput bool
|
||||
want string
|
||||
}{
|
||||
// safePath already has extension: always return as-is
|
||||
{name: "user path with ext, no CD", safePath: "out.xlsx", contentType: "application/pdf", userSpecifiedOutput: true, want: "out.xlsx"},
|
||||
{name: "user path with ext, CD present", safePath: "out.xlsx", contentDisposition: `attachment; filename="server.pdf"`, userSpecifiedOutput: true, want: "out.xlsx"},
|
||||
// No --output: use CD filename when present
|
||||
{name: "default path, CD filename", safePath: "file_xxx", contentDisposition: `attachment; filename="季度报告.xlsx"`, want: "季度报告.xlsx"},
|
||||
{name: "default path, CD RFC5987", safePath: "file_xxx", contentDisposition: `attachment; filename*=UTF-8''%E5%AD%A3%E5%BA%A6%E6%8A%A5%E5%91%8A.xlsx`, want: "季度报告.xlsx"},
|
||||
{name: "default path, no CD, MIME ext", safePath: "file_xxx", contentType: "application/pdf", want: "file_xxx.pdf"},
|
||||
{name: "default path, no CD, unknown MIME", safePath: "file_xxx", contentType: "application/x-unknown", want: "file_xxx"},
|
||||
{name: "default path, CD with dir component", safePath: "downloads/file_xxx", contentDisposition: `attachment; filename="report.xlsx"`, want: "downloads/report.xlsx"},
|
||||
// User --output without extension: use CD filename's extension
|
||||
{name: "user path no ext, CD with ext", safePath: "myfile", contentDisposition: `attachment; filename="server.pdf"`, userSpecifiedOutput: true, want: "myfile.pdf"},
|
||||
{name: "user path no ext, CD no ext, MIME ext", safePath: "myfile", contentDisposition: `attachment; filename="noext"`, contentType: "image/png", userSpecifiedOutput: true, want: "myfile.png"},
|
||||
{name: "user path no ext, no CD, MIME ext", safePath: "myfile", contentType: "image/jpeg", userSpecifiedOutput: true, want: "myfile.jpg"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveIMResourceDownloadPath(tt.safePath, tt.contentType, tt.contentDisposition, tt.userSpecifiedOutput)
|
||||
if got != tt.want {
|
||||
t.Fatalf("resolveIMResourceDownloadPath() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcuts(t *testing.T) {
|
||||
var commands []string
|
||||
for _, shortcut := range Shortcuts() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -31,7 +32,7 @@ var ImMessagesResourcesDownload = common.Shortcut{
|
||||
{Name: "message-id", Desc: "message ID (om_xxx)", Required: true},
|
||||
{Name: "file-key", Desc: "resource key (img_xxx or file_xxx)", Required: true},
|
||||
{Name: "type", Desc: "resource type (image or file)", Required: true, Enum: []string{"image", "file"}},
|
||||
{Name: "output", Desc: "local save path (relative only, no .. traversal; defaults to file_key)"},
|
||||
{Name: "output", Desc: "local save path (relative only, no .. traversal); when omitted, uses the server's Content-Disposition filename if available, otherwise file_key; extension is inferred from Content-Disposition or Content-Type if not provided"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
fileKey := runtime.Str("file-key")
|
||||
@@ -72,7 +73,8 @@ var ImMessagesResourcesDownload = common.Shortcut{
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath)
|
||||
userSpecifiedOutput := runtime.Str("output") != ""
|
||||
finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath, userSpecifiedOutput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -262,18 +264,21 @@ func initialIMResourceDownloadHeaders(fileType string) map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, outputPath string) (string, int64, error) {
|
||||
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, outputPath string, userSpecifiedOutput bool) (string, int64, error) {
|
||||
downloadResp, err := doIMResourceDownloadRequest(ctx, runtime, messageID, fileKey, fileType, initialIMResourceDownloadHeaders(fileType))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if downloadResp == nil {
|
||||
return "", 0, output.ErrNetwork("download failed: empty response")
|
||||
}
|
||||
|
||||
if downloadResp.StatusCode >= 400 {
|
||||
defer downloadResp.Body.Close()
|
||||
return "", 0, downloadResponseError(downloadResp)
|
||||
}
|
||||
|
||||
finalPath := resolveIMResourceDownloadPath(outputPath, downloadResp.Header.Get("Content-Type"))
|
||||
finalPath := resolveIMResourceDownloadPath(outputPath, downloadResp.Header.Get("Content-Type"), downloadResp.Header.Get("Content-Disposition"), userSpecifiedOutput)
|
||||
|
||||
var (
|
||||
body io.ReadCloser
|
||||
@@ -316,18 +321,63 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
||||
return savedPath, result.Size(), nil
|
||||
}
|
||||
|
||||
func resolveIMResourceDownloadPath(safePath, contentType string) string {
|
||||
func resolveIMResourceDownloadPath(safePath, contentType, contentDisposition string, userSpecifiedOutput bool) string {
|
||||
if filepath.Ext(safePath) != "" {
|
||||
return safePath
|
||||
}
|
||||
mimeType := strings.Split(contentType, ";")[0]
|
||||
mimeType = strings.TrimSpace(mimeType)
|
||||
if cdFilename := parseContentDispositionFilename(contentDisposition); cdFilename != "" {
|
||||
if !userSpecifiedOutput {
|
||||
// No --output flag: use the original filename from the server.
|
||||
dir := filepath.Dir(safePath)
|
||||
if dir == "." {
|
||||
return cdFilename
|
||||
}
|
||||
return filepath.Join(dir, cdFilename)
|
||||
}
|
||||
// User specified a path without extension: append the extension from the CD filename.
|
||||
if ext := filepath.Ext(cdFilename); ext != "" {
|
||||
return safePath + ext
|
||||
}
|
||||
}
|
||||
mimeType := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||
if ext, ok := imMimeToExt[mimeType]; ok {
|
||||
return safePath + ext
|
||||
}
|
||||
return safePath
|
||||
}
|
||||
|
||||
// parseContentDispositionFilename extracts and sanitizes the filename from a
|
||||
// Content-Disposition header. It handles RFC 5987 encoded filenames (filename*)
|
||||
// with priority over plain filename via the standard mime package.
|
||||
// Returns an empty string if no valid filename can be extracted.
|
||||
func parseContentDispositionFilename(header string) string {
|
||||
if header == "" {
|
||||
return ""
|
||||
}
|
||||
_, params, err := mime.ParseMediaType(header)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name := strings.TrimSpace(params["filename"])
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
// Strip any path component (Unix or Windows style) to prevent path traversal.
|
||||
if i := strings.LastIndexAny(name, "/\\"); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return ""
|
||||
}
|
||||
// Reject control characters (including null bytes).
|
||||
for _, r := range name {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType string, headers map[string]string) (*http.Response, error) {
|
||||
query := larkcore.QueryParams{}
|
||||
query.Set("type", fileType)
|
||||
|
||||
@@ -119,6 +119,29 @@ Drive Folder (云空间文件夹)
|
||||
|
||||
### 评论查询与统计口径(关键!)
|
||||
|
||||
**强制规则**:`drive file.comments list` 默认必须传 `is_solved:false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到要包含已解决评论,仍然按默认口径查询未解决评论。
|
||||
仅当用户明确要求包含已解决评论时,才可省略 `is_solved` 参数。
|
||||
|
||||
**正确示例:**
|
||||
|
||||
```bash
|
||||
# 默认查询:仅未解决评论(推荐)
|
||||
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx", "is_solved": false}'
|
||||
|
||||
# 查询所有评论(用户未明确要求包含已解决评论)
|
||||
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx", "is_solved": false}'
|
||||
|
||||
# 包含已解决评论(需用户明确要求)
|
||||
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx"}'
|
||||
```
|
||||
|
||||
**错误示例:**
|
||||
|
||||
```bash
|
||||
# 不推荐:用户未明确要求但查询所有评论
|
||||
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx"}'
|
||||
```
|
||||
|
||||
- 查询文档评论时,使用 `drive file.comments list`。
|
||||
- `drive file.comments list` 返回的 `items` 应理解为"评论卡片"列表,每个 `item` 对应用户界面里看到的一张评论卡片,而不是平铺的互动消息列表。
|
||||
- 服务端语义上,创建第一条评论时会同时创建该卡片里的第一条 reply;因此真正承载正文的是每个 `item.reply_list.replies`,其中第一条 reply 在用户视角下就是这张卡片里的"评论本身"。
|
||||
|
||||
@@ -117,7 +117,6 @@ lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}'
|
||||
| **转发** | `+forward` | `+forward --confirm-send` | `+forward --confirm-send --send-time <unix_timestamp>` |
|
||||
|
||||
- 有原邮件上下文 → 用 `+reply` / `+reply-all` / `+forward`(默认即草稿),**不要用 `+draft-create`**
|
||||
<<<<<<< HEAD
|
||||
- **发送前必须向用户确认收件人和内容;如有必要,可引导用户去飞书邮件里打开草稿查看详情;用户明确同意后才可执行发送或使用 `--confirm-send`**
|
||||
- **发送后必须调用 `send_status` 确认投递状态**;定时发送(`--send-time`)在预定发送时间后再查询,取消定时发送用 `cancel_scheduled_send`(详见下方说明)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-base
|
||||
version: 1.2.0
|
||||
description: "当需要用 lark-cli 操作飞书多维表格(Base)时调用:适用于建表、字段管理、记录读写、视图配置、历史查询,以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
|
||||
description: "当需要用 lark-cli 操作飞书多维表格(Base)时调用:适用于建表、字段管理、记录读写、记录分享链接、视图配置、历史查询,以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -107,6 +107,7 @@ metadata:
|
||||
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token` 从 `+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403) |
|
||||
| `+record-delete / +record-history-list` | 删除记录,或查询某条记录的变更历史 | [`lark-base-record-delete.md`](references/lark-base-record-delete.md)、[`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 删除时用户已明确目标可直接执行并带 `--yes`;历史查询按 `table-id + record-id`,不支持整表扫描;`+record-history-list` 只能串行执行 |
|
||||
| `+record-share-link-create` | 为一条或多条记录生成分享链接 | [`lark-base-record-share-link-create.md`](references/lark-base-record-share-link-create.md) | 单次最多 100 条;重复 record_id 会自动去重;适合分享单条记录或批量分享场景 |
|
||||
|
||||
#### 2.3.4 View 子模块
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# base +record-share-link-create
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
为一条或多条记录生成分享链接(单次调用最多传入 100 条,内部调用批量接口)。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 单条记录
|
||||
lark-cli base +record-share-link-create \
|
||||
--base-token xxx \
|
||||
--table-id tbl_xxx \
|
||||
--record-ids rec_xxx
|
||||
|
||||
# 多条记录(使用 "," 分隔)
|
||||
lark-cli base +record-share-link-create \
|
||||
--base-token xxx \
|
||||
--table-id tbl_xxx \
|
||||
--record-ids rec001,rec002,rec003
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--table-id <id>` | 是 | 表 ID |
|
||||
| `--record-ids <ids...>` | 是 | 记录 ID 列表,逗号分隔或重复使用该标志,最多 100 条 |
|
||||
|
||||
## API 入参详情
|
||||
|
||||
**HTTP 方法和路径:**
|
||||
|
||||
```http
|
||||
POST /open-apis/base/v3/bases/:base_token/tables/:table_id/records/share_links/batch
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
|
||||
```json
|
||||
{
|
||||
"record_ids": ["rec001", "rec002", "rec003"]
|
||||
}
|
||||
```
|
||||
|
||||
> CLI 会自动对 `--record-ids` 去重后再调用接口。
|
||||
|
||||
## 返回重点
|
||||
|
||||
- 成功时直接返回接口 `data` 字段内容,包含 `record_share_links` 映射(key 为 record_id,value 为分享链接)。结构如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"record_share_links": {
|
||||
"rec001": "https://example.feishu.cn/record/TW2wrdbkoeoYXYcwvyIczJ2ZnFb"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 若部分记录 ID 无权限/不存在,则 `record_share_links` 中只会包含有效记录对应的分享链接
|
||||
- 若全部记录 ID 都无权限/不存在,则会返回错误信息 `records do not exist or no read permission`
|
||||
|
||||
## 坑点
|
||||
|
||||
- ⚠️ 单次最多 100 条记录,超出会被 CLI 校验拦截。
|
||||
- ⚠️ 重复的 record_id 会在调用前自动去重。
|
||||
- ⚠️ `--record-ids` 为空时会被校验拦截。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base-record.md](lark-base-record.md) — record 索引页
|
||||
@@ -17,6 +17,7 @@ record 相关命令索引。
|
||||
| [lark-base-record-upload-attachment.md](lark-base-record-upload-attachment.md) | `+record-upload-attachment` | 上传本地文件到附件字段并更新记录 |
|
||||
| [`../../lark-doc/references/lark-doc-media-download.md`](../../lark-doc/references/lark-doc-media-download.md) | `lark-cli docs +media-download` | 下载 Base 附件到本地(附件的 `file_token` 来自 `+record-get` 的附件字段) |
|
||||
| [lark-base-record-delete.md](lark-base-record-delete.md) | `+record-delete` | 删除记录 |
|
||||
| [lark-base-record-share-link-create.md](lark-base-record-share-link-create.md) | `+record-share-link-create` | 生成记录分享链接(支持单条或批量,最多 100 条)|
|
||||
|
||||
## 说明
|
||||
|
||||
|
||||
@@ -8,14 +8,22 @@
|
||||
## 重要说明
|
||||
> **⚠️ 本文档中提到的 html 标签不需要在 Markdown 中转义!若转义,会导致相关的表格,多维表格,画板等 block 插入失败**
|
||||
|
||||
> **⚠️ `\n` 不是换行:** `--markdown "...\n..."` 里的 `\n` 在 shell 里是字面反斜杠 + n,会作为文字写入文档。请用真实换行:多行字符串、heredoc (`--markdown -`)、或 `$'...\n...'`(bash/zsh)。示例见下方。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 创建简单文档
|
||||
lark-cli docs +create --title "项目计划" --markdown "## 目标\n\n- 目标 1\n- 目标 2"
|
||||
lark-cli docs +create --title "项目计划" --markdown "## 目标
|
||||
|
||||
- 目标 1
|
||||
- 目标 2"
|
||||
|
||||
# 创建到指定文件夹
|
||||
lark-cli docs +create --title "会议纪要" --folder-token fldcnXXXX --markdown "## 讨论议题\n\n1. 进度\n2. 计划"
|
||||
lark-cli docs +create --title "会议纪要" --folder-token fldcnXXXX --markdown "## 讨论议题
|
||||
|
||||
1. 进度
|
||||
2. 计划"
|
||||
|
||||
# 创建到知识库节点下
|
||||
lark-cli docs +create --title "技术文档" --wiki-node wikcnXXXX --markdown "## API 说明"
|
||||
@@ -25,6 +33,19 @@ lark-cli docs +create --title "概览" --wiki-space 7000000000000000000 --markdo
|
||||
|
||||
# 创建到个人知识库
|
||||
lark-cli docs +create --title "学习笔记" --wiki-space my_library --markdown "## 笔记"
|
||||
|
||||
# 从 stdin 读取(适合较长的 markdown 内容)
|
||||
lark-cli docs +create --title "长文档" --markdown - <<'MD'
|
||||
## 目标
|
||||
|
||||
- 目标 1
|
||||
- 目标 2
|
||||
|
||||
## 计划
|
||||
|
||||
1. 第一步
|
||||
2. 第二步
|
||||
MD
|
||||
```
|
||||
|
||||
## 返回值
|
||||
@@ -116,13 +137,22 @@ wiki_space 可以从知识空间设置页面 URL 中获取,格式如:`https:
|
||||
### 示例 1:创建简单文档
|
||||
|
||||
```bash
|
||||
lark-cli docs +create --title "项目计划" --markdown "## 项目概述\n\n这是一个新项目。\n\n## 目标\n\n- 目标 1\n- 目标 2"
|
||||
lark-cli docs +create --title "项目计划" --markdown "## 项目概述
|
||||
|
||||
这是一个新项目。
|
||||
|
||||
## 目标
|
||||
|
||||
- 目标 1
|
||||
- 目标 2"
|
||||
```
|
||||
|
||||
### 示例 2:使用飞书扩展语法
|
||||
|
||||
```bash
|
||||
lark-cli docs +create --title "产品需求" --markdown '<callout emoji="💡" background-color="light-blue">\n重要需求说明\n</callout>'
|
||||
lark-cli docs +create --title "产品需求" --markdown '<callout emoji="💡" background-color="light-blue">
|
||||
重要需求说明
|
||||
</callout>'
|
||||
```
|
||||
|
||||
# 内容格式
|
||||
|
||||
@@ -8,17 +8,23 @@
|
||||
## 重要说明
|
||||
> **⚠️ 本文档中提到的 html 标签不需要在 Markdown 中转义!若转义,会导致相关的表格,多维表格,画板等 block 插入失败**
|
||||
|
||||
> **⚠️ `\n` 不是换行:** `--markdown "...\n..."` 里的 `\n` 在 shell 里是字面反斜杠 + n,会作为文字写入文档。请用真实换行:多行字符串、heredoc (`--markdown -`)、或 `$'...\n...'`(bash/zsh)。示例见下方。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 追加内容
|
||||
lark-cli docs +update --doc "<doc_id_or_url>" --mode append --markdown "## 新章节\n\n追加内容"
|
||||
lark-cli docs +update --doc "<doc_id_or_url>" --mode append --markdown "## 新章节
|
||||
|
||||
追加内容"
|
||||
|
||||
# 定位替换(内容定位)
|
||||
lark-cli docs +update --doc "<doc_id>" --mode replace_range --selection-with-ellipsis "旧标题...旧结尾" --markdown "## 新内容"
|
||||
|
||||
# 定位替换(标题定位)
|
||||
lark-cli docs +update --doc "<doc_id>" --mode replace_range --selection-by-title "## 功能说明" --markdown "## 功能说明\n\n新内容"
|
||||
lark-cli docs +update --doc "<doc_id>" --mode replace_range --selection-by-title "## 功能说明" --markdown "## 功能说明
|
||||
|
||||
新内容"
|
||||
|
||||
# 全文替换
|
||||
lark-cli docs +update --doc "<doc_id>" --mode replace_all --selection-with-ellipsis "张三" --markdown "李四"
|
||||
@@ -38,7 +44,7 @@ lark-cli docs +update --doc "<doc_id>" --mode overwrite --markdown "# 全新内
|
||||
# 同时更新标题
|
||||
lark-cli docs +update --doc "<doc_id>" --mode append --markdown "## 更新日志" --new-title "文档 v2.0"
|
||||
|
||||
# 在指定内容后新增两个空白画板
|
||||
# 在指定内容后新增两个空白画板(ANSI-C 引用,适合短标签序列)
|
||||
lark-cli docs +update --doc "<doc_id>" --mode insert_after --selection-with-ellipsis "有序列表" --markdown $'<whiteboard type="blank"></whiteboard>\n<whiteboard type="blank"></whiteboard>'
|
||||
```
|
||||
|
||||
@@ -143,19 +149,25 @@ lark-cli docs +update --doc "<doc_id>" --mode insert_after --selection-with-elli
|
||||
## append - 追加到末尾
|
||||
|
||||
```bash
|
||||
lark-cli docs +update --doc "文档ID或URL" --mode append --markdown "## 新章节\n\n追加的内容..."
|
||||
lark-cli docs +update --doc "文档ID或URL" --mode append --markdown "## 新章节
|
||||
|
||||
追加的内容..."
|
||||
```
|
||||
|
||||
## replace_range - 定位替换
|
||||
|
||||
使用 `--selection-with-ellipsis`:
|
||||
```bash
|
||||
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-with-ellipsis "## 旧标题...旧结尾。" --markdown "## 新标题\n\n新的内容..."
|
||||
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-with-ellipsis "## 旧标题...旧结尾。" --markdown "## 新标题
|
||||
|
||||
新的内容..."
|
||||
```
|
||||
|
||||
使用 `--selection-by-title`(替换整个章节):
|
||||
```bash
|
||||
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-by-title "## 功能说明" --markdown "## 功能说明\n\n更新后的内容..."
|
||||
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-by-title "## 功能说明" --markdown "## 功能说明
|
||||
|
||||
更新后的内容..."
|
||||
```
|
||||
|
||||
## replace_all - 全文替换
|
||||
@@ -184,7 +196,9 @@ lark-cli docs +update --doc "文档ID" --mode delete_range --selection-by-title
|
||||
⚠️ 会清空文档后重写,可能丢失图片、评论等,仅在需要完全重建文档时使用。
|
||||
|
||||
```bash
|
||||
lark-cli docs +update --doc "文档ID" --mode overwrite --markdown "# 新文档\n\n全新的内容..."
|
||||
lark-cli docs +update --doc "文档ID" --mode overwrite --markdown "# 新文档
|
||||
|
||||
全新的内容..."
|
||||
```
|
||||
|
||||
## 创建空白画板
|
||||
|
||||
@@ -132,6 +132,28 @@ Drive Folder (云空间文件夹)
|
||||
|
||||
### 评论查询与统计口径(关键!)
|
||||
|
||||
**强制规则**:`drive file.comments list` 默认必须传 `is_solved:false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到要包含已解决评论,仍然按默认口径查询未解决评论。仅当用户明确要求包含已解决评论时,才可省略 `is_solved` 参数。
|
||||
|
||||
**正确示例:**
|
||||
|
||||
```bash
|
||||
# 默认查询:仅未解决评论(推荐)
|
||||
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx", "is_solved": false}'
|
||||
|
||||
# 查询所有评论(用户未明确要求包含已解决评论)
|
||||
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx", "is_solved": false}'
|
||||
|
||||
# 包含已解决评论(需用户明确要求)
|
||||
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx"}'
|
||||
```
|
||||
|
||||
**错误示例:**
|
||||
|
||||
```bash
|
||||
# 不推荐:用户未明确要求但查询所有评论
|
||||
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx"}'
|
||||
```
|
||||
|
||||
- 查询文档评论时,使用 `drive file.comments list`。
|
||||
- `drive file.comments list` 返回的 `items` 应理解为"评论卡片"列表,每个 `item` 对应用户界面里看到的一张评论卡片,而不是平铺的互动消息列表。
|
||||
- 服务端语义上,创建第一条评论时会同时创建该卡片里的第一条 reply;因此真正承载正文的是每个 `item.reply_list.replies`,其中第一条 reply 在用户视角下就是这张卡片里的"评论本身"。
|
||||
@@ -196,7 +218,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to Drive |
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
@@ -207,6 +229,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
|
||||
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |
|
||||
|
||||
## API Resources
|
||||
|
||||
|
||||
77
skills/lark-drive/references/lark-drive-apply-permission.md
Normal file
77
skills/lark-drive/references/lark-drive-apply-permission.md
Normal file
@@ -0,0 +1,77 @@
|
||||
|
||||
# drive +apply-permission(申请文档权限)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli drive +apply-permission`。
|
||||
|
||||
向云文档 **Owner** 发起 `view` 或 `edit` 权限申请。申请会以卡片形式推送给 Owner,由 Owner 决定是否通过。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 会给 Owner 发推送通知,不要批量或自动化调用。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 身份要求
|
||||
|
||||
- **仅支持 `user` 身份**(使用 `user_access_token`),不支持 `bot` / `tenant_access_token`;shortcut 已在 `AuthTypes` 中强制限定为 `user`,使用 bot 会被拒。
|
||||
- 所需 scope:`docs:permission.member:apply`(若用户缺权限会走统一的 permission 错误路径)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 通过 URL 申请(type 自动从 URL 推断)
|
||||
lark-cli drive +apply-permission \
|
||||
--token "https://example.larksuite.com/docx/doxcnxxxxxxxxx" \
|
||||
--perm view \
|
||||
--remark "安全评估:需查看需求文档内容" --as user
|
||||
|
||||
# 通过 bare token + 显式 --type
|
||||
lark-cli drive +apply-permission \
|
||||
--token "doxcnxxxxxxxxx" --type docx \
|
||||
--perm edit --as user
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 目标文档 token 或完整 URL(`/docx/`、`/sheets/`、`/base/`、`/bitable/`、`/file/`、`/wiki/`、`/doc/`、`/mindnote/`、`/slides/` 路径里的 token 会被自动提取) |
|
||||
| `--type` | 否 | 目标类型,可选值 `doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `slides`。传 URL 时可由 shortcut 自动推断;bare token 必须显式传 |
|
||||
| `--perm` | 是 | 申请的权限,仅支持 `view` 或 `edit`(**不支持 `full_access`**,CLI 侧会直接拒绝) |
|
||||
| `--remark` | 否 | 备注,会显示在权限申请卡片上 |
|
||||
| `--dry-run` | 否 | 仅打印请求内容,不实际发送 |
|
||||
|
||||
## 输出
|
||||
|
||||
API 成功时返回空 `data`(仅 `code: 0, msg: "success"`),对应 CLI 输出:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
## 频率限制
|
||||
|
||||
- **应用级**:每应用每租户每分钟最多 10 次。
|
||||
- **用户级**:同一用户对**同一篇文档**一天不超过 5 次。
|
||||
|
||||
## 常见错误
|
||||
|
||||
| 错误码 | 含义 | CLI 处理 |
|
||||
|---|---|---|
|
||||
| `1063006` | 申请次数已达上限(5 次/日) | CLI 自动加 hint:`permission-apply quota reached: each user may request access on the same document at most 5 times per day` |
|
||||
| `1063007` | 当前文档无法申请(如:文档禁用外部申请、申请者已拥有对应权限、目标类型不支持 apply) | CLI 自动加 hint:`this document does not accept a permission-apply request ... contact the owner directly` |
|
||||
| `1063002` | 无操作权限(如该租户关闭了外部申请) | 由统一 permission 错误路径处理 |
|
||||
| `1063004` | 用户所在组织无分享权限 | 由统一 permission 错误路径处理 |
|
||||
| `1063005` | 资源已删除 | 需要确认目标文档/节点是否仍存在 |
|
||||
| `1066001/1066002` | 服务端异常 / 并发冲突 | 稍后重试 |
|
||||
|
||||
## 与 wiki URL 的关系
|
||||
|
||||
传入 `/wiki/<node_token>` 时,shortcut 会直接用 `node_token` 作为路径参数并以 `type=wiki` 调用接口。如果需要先把 wiki 节点解析成 `obj_token`(例如想显式对底层 docx 申请),自行先调 `wiki spaces get_node` 拿 `obj_token + obj_type`,再用 bare token + `--type docx` 调本命令。
|
||||
|
||||
## 参考
|
||||
|
||||
- OpenAPI 端点:`POST /open-apis/drive/v1/permissions/:token/members/apply`
|
||||
@@ -34,7 +34,7 @@ lark-cli im +messages-resources-download --message-id om_xxx --file-key img_v3_x
|
||||
| `--message-id <id>` | Yes | Message ID (`om_xxx` format) |
|
||||
| `--file-key <key>` | Yes | Resource key (`img_xxx` or `file_xxx`) |
|
||||
| `--type <type>` | Yes | Resource type: `image` or `file` |
|
||||
| `--output <path>` | No | Output path (relative paths only; `..` traversal is not allowed; defaults to `file_key` as the file name). File extension is automatically added based on Content-Type if not provided |
|
||||
| `--output <path>` | No | Output path (relative paths only; `..` traversal is not allowed). When omitted, the server's original filename from `Content-Disposition` is used if available; otherwise defaults to `file_key`. File extension is automatically inferred from `Content-Disposition` or `Content-Type` if not provided |
|
||||
| `--as <identity>` | No | Identity type: `user` (default) or `bot` |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
|
||||
@@ -51,7 +51,7 @@ When downloading large files, the command automatically uses **HTTP Range reques
|
||||
|
||||
**Benefits:**
|
||||
- Reduces the impact of transient request failures during large downloads
|
||||
- Automatically detects and appends correct file extension from Content-Type
|
||||
- Preserves the server's original filename via `Content-Disposition` (supports RFC 5987 UTF-8 encoding); falls back to `Content-Type`-based extension inference
|
||||
- Validates file size integrity after download completion
|
||||
|
||||
## `file_key` Sources
|
||||
|
||||
@@ -132,7 +132,6 @@ lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}'
|
||||
| **转发** | `+forward` | `+forward --confirm-send` | `+forward --confirm-send --send-time <unix_timestamp>` |
|
||||
|
||||
- 有原邮件上下文 → 用 `+reply` / `+reply-all` / `+forward`(默认即草稿),**不要用 `+draft-create`**
|
||||
<<<<<<< HEAD
|
||||
- **发送前必须向用户确认收件人和内容;如有必要,可引导用户去飞书邮件里打开草稿查看详情;用户明确同意后才可执行发送或使用 `--confirm-send`**
|
||||
- **发送后必须调用 `send_status` 确认投递状态**;定时发送(`--send-time`)在预定发送时间后再查询,取消定时发送用 `cancel_scheduled_send`(详见下方说明)
|
||||
|
||||
@@ -415,7 +414,6 @@ lark-cli mail <resource> <method> [flags] # 调用 API
|
||||
|
||||
### user_mailbox.settings
|
||||
|
||||
- `get_signatures` — 获取用户邮箱签名列表
|
||||
- `send_as` — 获取账号的所有可发信地址,包括主地址、别名地址、邮件组。可以使用用户地址访问该接口,也可以使用用户有权限的公共邮箱地址访问该接口。
|
||||
|
||||
### user_mailbox.threads
|
||||
@@ -477,7 +475,6 @@ lark-cli mail <resource> <method> [flags] # 调用 API
|
||||
| `user_mailbox.rules.list` | `mail:user_mailbox.rule:read` |
|
||||
| `user_mailbox.rules.reorder` | `mail:user_mailbox.rule:write` |
|
||||
| `user_mailbox.rules.update` | `mail:user_mailbox.rule:write` |
|
||||
| `user_mailbox.settings.get_signatures` | `mail:user_mailbox:readonly` |
|
||||
| `user_mailbox.settings.send_as` | `mail:user_mailbox:readonly` |
|
||||
| `user_mailbox.threads.batch_modify` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.threads.batch_trash` | `mail:user_mailbox.message:modify` |
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
|
||||
**用户 prompt 简短/模糊时**(如"画个漏斗图"、"画个架构图"),不要只输出字面内容。应适当补充该领域合理的内容
|
||||
|
||||
## 图片需求识别
|
||||
|
||||
> **在规划内容之前,先判断是否需要插入真实图片。**
|
||||
|
||||
**触发条件(严格)**:仅当用户**显式说了**「图片、配图、插图、照片、真实图片、实拍」等词时,才使用 image 节点。
|
||||
|
||||
**不触发**:即使主题是旅行、美食、产品等视觉性内容,只要用户没显式要求图片,就不使用 image 节点,用文字 + 形状 + icon 呈现。
|
||||
|
||||
**识别到图片需求后**:参考 [`references/image.md`](image.md) 完成 Step 0(图片准备),再回来继续内容规划。
|
||||
|
||||
**图片数量规划**:3-6 张为宜。少于 3 张显得单薄,多于 6 张增加准备时间且布局拥挤。
|
||||
|
||||
## 信息量参考
|
||||
|
||||
| 用户需求 | 合理的信息量 |
|
||||
|
||||
57
skills/lark-whiteboard/references/image.md
Normal file
57
skills/lark-whiteboard/references/image.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 图片资源处理
|
||||
|
||||
## 图片需求识别
|
||||
|
||||
**触发条件(严格)**:仅当用户**显式要求**使用图片时,才使用 image 节点。触发关键词:
|
||||
|
||||
> 图片、配图、插图、照片、真实图片、实拍、放一张图、加个图、嵌入图片
|
||||
|
||||
**不触发的情况**:即使主题涉及旅行、美食、产品、人物等视觉性内容,只要用户没有显式说要「图片/配图/插图」,就**一律不使用 image 节点**,用文字 + 形状 + icon 来呈现即可。
|
||||
|
||||
识别到图片需求后,先完成下方 Step 0,再回到 [DSL 路径 Workflow](../routes/dsl.md) 继续 Step 2(生成 DSL)。
|
||||
|
||||
**图片数量**:3-6 张为宜。
|
||||
|
||||
## Step 0:图片准备
|
||||
|
||||
1. 识别图片需求(见上方触发关键词表)
|
||||
2. 确定需要几张图,为每张图准备不同的搜索关键词(英文)
|
||||
3. 逐张下载 → 校验每张图不同(文件大小) → 逐张上传到飞书 Drive
|
||||
4. 收集所有 file_token,在 Step 2 生成 DSL 时引用
|
||||
|
||||
## 上传步骤
|
||||
|
||||
**单张图片**:
|
||||
```bash
|
||||
curl -L -o palace.jpg "https://example.com/palace.jpg"
|
||||
lark-cli drive +upload --file ./palace.jpg
|
||||
# 响应: { "file_token": "<file_token>", ... }
|
||||
```
|
||||
|
||||
**多张图片(每张必须是不同的图)**:
|
||||
```bash
|
||||
# 1. 每张图用不同的搜索词/URL 下载
|
||||
curl -L -o forbidden-city.jpg "https://example.com/forbidden-city.jpg"
|
||||
curl -L -o great-wall.jpg "https://example.com/great-wall.jpg"
|
||||
curl -L -o temple.jpg "https://example.com/temple.jpg"
|
||||
|
||||
# 2. 校验每张图确实不同(比较文件大小,跨平台通用)
|
||||
ls -l *.jpg # 确认每张文件大小不同;若大小相同则内容可能重复,需重新下载
|
||||
|
||||
# 3. 逐张上传,收集 token
|
||||
lark-cli drive +upload --file ./forbidden-city.jpg # → <file_token_1>
|
||||
lark-cli drive +upload --file ./great-wall.jpg # → <file_token_2>
|
||||
lark-cli drive +upload --file ./temple.jpg # → <file_token_3>
|
||||
```
|
||||
|
||||
> **多图常见错误**:用同一个 URL 参数下载多次,导致多张图片完全相同。每张图必须用不同的搜索关键词或不同的图片 ID。
|
||||
|
||||
## 图片来源策略
|
||||
|
||||
| 来源 | 方式 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| 公开 URL | `curl -L -o file.jpg <URL>` 下载后上传 | 景点照片、开源图片 |
|
||||
| AI 生成 | 调用图片生成工具,保存后上传 | 插画、图标、概念图 |
|
||||
| 用户提供 | 用户给出本地路径或 URL | 产品截图、Logo |
|
||||
|
||||
> `image.src` 必须是飞书 Drive 的 `file_token`,不支持直接使用 URL。所有图片都需要先上传。
|
||||
@@ -372,3 +372,14 @@ npx -y @larksuite/whiteboard-cli@^0.2.0 -i skeleton.json -o step1.png -l coords.
|
||||
```
|
||||
|
||||
`alignItems: 'stretch'` + `width: 'fill-container'` = 等宽等高。
|
||||
|
||||
---
|
||||
|
||||
## 图文卡片
|
||||
|
||||
含图片的画板用图文卡片布局(vertical frame:图上文下):
|
||||
|
||||
- image 宽度 = 卡片宽度,height 按 3:2 比例(如 240×160)
|
||||
- 卡片间 gap: 24(比纯文字间距大)
|
||||
- 多卡片一行超过 3 张时,换行用嵌套 horizontal frame
|
||||
- 详见 `scenes/photo-showcase.md`
|
||||
|
||||
@@ -237,6 +237,81 @@ SVG 通过 `image/svg+xml` Blob 加载到画布,**不在 HTML DOM 中**,因
|
||||
"svg": { "code": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#3B82F6\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><polyline points=\"12 6 12 12 16 14\"/></svg>" } }
|
||||
```
|
||||
|
||||
### Image(图片节点)
|
||||
|
||||
在画板中嵌入图片。图片需先上传到飞书 Drive 获取 `file_token`。
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'image';
|
||||
id?: string;
|
||||
x?: number; y?: number;
|
||||
opacity?: number; // 0-1
|
||||
width: WBSizeValue;
|
||||
height: WBSizeValue;
|
||||
image: { src: string }; // 飞书 Drive file_token
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用要求
|
||||
|
||||
图片必须先上传到飞书 Drive,`image.src` 填写返回的 `file_token`(如 `"T8SBbLB5co85YLxuX8icHAlrnZg"`)。
|
||||
|
||||
**上传步骤**:
|
||||
```bash
|
||||
# 1. 上传图片到飞书 Drive
|
||||
lark-cli drive +upload --file ./beijing-palace.jpg
|
||||
|
||||
# 2. 从响应中提取 file_token,填入 DSL
|
||||
```
|
||||
|
||||
#### 尺寸建议
|
||||
|
||||
| 用途 | 推荐尺寸 | 说明 |
|
||||
|------|----------|------|
|
||||
| 卡片插图 | 200×150 ~ 300×200 | 配合文字卡片使用 |
|
||||
| 全宽背景 | 与 frame 同宽,高度按比例 | 用于地图、背景板 |
|
||||
| 缩略图/头像 | 60×60 ~ 100×100 | 列表项中的小图 |
|
||||
|
||||
> **宽高比**:建议保持原始图片的宽高比,避免拉伸变形。如果不确定原始比例,使用正方形(如 200×200)。
|
||||
|
||||
#### 典型用法
|
||||
|
||||
**1. 图文卡片**(图片 + 文字描述)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "vertical", "gap": 8, "padding": 0,
|
||||
"width": 240, "height": "fit-content",
|
||||
"fillColor": "#FFFFFF", "borderWidth": 1, "borderColor": "#E0E0E0", "borderRadius": 12,
|
||||
"children": [
|
||||
{ "type": "image", "width": 240, "height": 160,
|
||||
"image": { "src": "<file_token>" } },
|
||||
{ "type": "frame", "layout": "vertical", "gap": 4, "padding": [8, 12, 12, 12],
|
||||
"width": "fill-container", "height": "fit-content",
|
||||
"children": [
|
||||
{ "type": "text", "text": "故宫博物院", "fontSize": 14, "width": "fill-container", "height": "fit-content" },
|
||||
{ "type": "text", "text": "世界最大的古代宫殿建筑群", "fontSize": 11, "textColor": "#666666", "width": "fill-container", "height": "fit-content" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**2. 图片网格**(多张图片平铺)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame", "layout": "horizontal", "gap": 16, "padding": 16,
|
||||
"width": "fit-content", "height": "fit-content",
|
||||
"children": [
|
||||
{ "type": "image", "width": 200, "height": 150, "image": { "src": "<token_1>" } },
|
||||
{ "type": "image", "width": 200, "height": 150, "image": { "src": "<token_2>" } },
|
||||
{ "type": "image", "width": 200, "height": 150, "image": { "src": "<token_3>" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Icon(内置图标)
|
||||
|
||||
引用画板内置图标库的图标。比手写 SVG 更简单——只需指定 `name`。
|
||||
|
||||
@@ -73,6 +73,7 @@ Step 3: 渲染 & 审查 → 交付
|
||||
| 循环/飞轮图 | `scenes/flywheel.md` | 增长飞轮、闭环链路 |
|
||||
| 里程碑 | `scenes/milestone.md` | 时间线、版本演进 |
|
||||
| 流程图 | `scenes/flowchart.md` | 业务流、状态机、带条件判断的链路 |
|
||||
| 图片展示 | `scenes/photo-showcase.md` | 用户显式要求图片/配图/插图时 |
|
||||
|
||||
## 渲染前自查
|
||||
|
||||
|
||||
122
skills/lark-whiteboard/scenes/photo-showcase.md
Normal file
122
skills/lark-whiteboard/scenes/photo-showcase.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# 图片展示 (Photo Showcase)
|
||||
|
||||
适用于:用户**显式要求使用图片/配图/插图**的场景(如"画一个带配图的旅行路线"、"做一个有图片的产品展示")。
|
||||
|
||||
> **注意**:仅当用户明确说了「图片/配图/插图/照片」等词时才进入本场景。单纯说"旅行路线图"、"产品展示"等不触发。
|
||||
> **前置条件**:进入本场景前,必须已完成 [`references/image.md`](../references/image.md) 的 Step 0(图片准备),拿到所有 file_token。
|
||||
|
||||
## Content 约束
|
||||
|
||||
- 图片 3-6 张,每张配标题(必需)+ 简短描述(可选,15字内)
|
||||
- **每张图必须是不同的真实图片**(不同 file_token),下载时用不同关键词/URL
|
||||
- 下载后用 `md5` 校验确保每张图不重复
|
||||
- 文字仅作辅助说明,图片是信息主体
|
||||
|
||||
## Layout 选型
|
||||
|
||||
| 模式 | 适用条件 | 特征 |
|
||||
|------|---------|------|
|
||||
| **卡片网格(默认)** | 多图平级展示(产品墙、团队介绍、美食推荐) | horizontal frame 内放等尺寸图文卡片 |
|
||||
| **路线时间线** | 有先后顺序(旅行路线、团建路线、项目演进) | 图文卡片 + connector 串联 |
|
||||
| **中心辐射** | 有一个核心主题 + 周围子项 | 中心标题 + 周围图文卡片 |
|
||||
|
||||
## Layout 规则
|
||||
|
||||
- **图文卡片结构**:vertical frame(图上文下),image 宽度 = 卡片宽度,height 按 3:2 比例
|
||||
- **卡片统一尺寸**:所有卡片宽高一致(推荐 240×280 或 200×250)
|
||||
- **图片统一尺寸**:所有 image 节点用相同 width/height(推荐 240×160 或 200×133)
|
||||
- **卡片间距**:gap: 24(比纯文字图表间距更大,让图片呼吸)
|
||||
- **卡片样式**:白色底 + 圆角 12 + 细边框,image 无圆角(紧贴卡片顶部)
|
||||
- **有序路线时**:卡片间用 connector 连接,connector 放顶层 nodes 数组
|
||||
|
||||
## 骨架示例
|
||||
|
||||
### 卡片网格(产品展示/团队介绍/美食推荐)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"type": "frame", "id": "grid", "layout": "vertical", "gap": 24, "padding": 32,
|
||||
"width": 840, "height": "fit-content",
|
||||
"children": [
|
||||
{ "type": "text", "id": "title", "width": 776, "height": 36,
|
||||
"text": "图表标题", "fontSize": 24, "textAlign": "center" },
|
||||
{
|
||||
"type": "frame", "id": "row", "layout": "horizontal", "gap": 24, "padding": 0,
|
||||
"width": "fit-content", "height": "fit-content",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame", "id": "card-1", "layout": "vertical", "gap": 8, "padding": [0, 0, 12, 0],
|
||||
"width": 240, "height": "fit-content",
|
||||
"fillColor": "#FFFFFF", "borderWidth": 1, "borderColor": "#E0E0E0", "borderRadius": 12,
|
||||
"children": [
|
||||
{ "type": "image", "id": "img-1", "width": 240, "height": 160, "image": { "src": "<token_1>" } },
|
||||
{ "type": "text", "id": "t-1", "text": "标题", "fontSize": 14, "width": 216, "height": 20 },
|
||||
{ "type": "text", "id": "d-1", "text": "简短描述", "fontSize": 11, "textColor": "#666666", "width": 216, "height": 16 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
每张图文卡片结构相同,复制并替换 `<token_N>`、标题和描述即可。3 张卡片一行,超过 3 张换行(嵌套第二个 horizontal frame)。
|
||||
|
||||
### 路线时间线(旅行路线/团建路线)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"type": "frame", "id": "route", "layout": "vertical", "gap": 24, "padding": 32,
|
||||
"width": 1100, "height": "fit-content",
|
||||
"children": [
|
||||
{ "type": "text", "id": "title", "width": 1036, "height": 36,
|
||||
"text": "路线标题", "fontSize": 24, "textAlign": "center" },
|
||||
{
|
||||
"type": "frame", "id": "stops", "layout": "horizontal", "gap": 32, "padding": 0,
|
||||
"width": "fit-content", "height": "fit-content",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame", "id": "stop-1", "layout": "vertical", "gap": 8, "padding": [0, 0, 12, 0],
|
||||
"width": 240, "height": "fit-content",
|
||||
"fillColor": "#FFFFFF", "borderWidth": 1, "borderColor": "#E0E0E0", "borderRadius": 12,
|
||||
"children": [
|
||||
{ "type": "image", "id": "img-1", "width": 240, "height": 160, "image": { "src": "<token_1>" } },
|
||||
{ "type": "text", "id": "t-1", "text": "第1站:地点名", "fontSize": 14, "width": 216, "height": 20 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame", "id": "stop-2", "layout": "vertical", "gap": 8, "padding": [0, 0, 12, 0],
|
||||
"width": 240, "height": "fit-content",
|
||||
"fillColor": "#FFFFFF", "borderWidth": 1, "borderColor": "#E0E0E0", "borderRadius": 12,
|
||||
"children": [
|
||||
{ "type": "image", "id": "img-2", "width": 240, "height": 160, "image": { "src": "<token_2>" } },
|
||||
{ "type": "text", "id": "t-2", "text": "第2站:地点名", "fontSize": 14, "width": 216, "height": 20 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "type": "connector", "id": "c1", "connector": { "from": "stop-1", "to": "stop-2", "fromAnchor": "right", "toAnchor": "left" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
注意:connector 必须放在**顶层 nodes 数组**,不能嵌套在 frame.children 内。connector 的属性须包裹在 `connector` 字段中。
|
||||
|
||||
## 图片准备检查清单
|
||||
|
||||
生成 DSL 前确认:
|
||||
|
||||
- [ ] 所有 image 节点的 `image.src` 都是已上传的 file_token(非 URL)
|
||||
- [ ] 每个 file_token 不同(对应不同的真实图片)
|
||||
- [ ] 所有图片尺寸一致(同一画板内统一 width×height)
|
||||
- [ ] 图片宽高比合理(推荐 3:2,即 240×160)
|
||||
@@ -1,20 +1,22 @@
|
||||
# Drive CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 28 leaf commands
|
||||
- Covered: 1
|
||||
- Coverage: 3.6%
|
||||
- Denominator: 29 leaf commands
|
||||
- Covered: 2
|
||||
- Coverage: 6.9%
|
||||
|
||||
## Summary
|
||||
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
|
||||
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
- Blocked area: upload, export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
|
||||
- Blocked area: upload, export, comment, subscription, and reply flows still need deterministic remote fixtures and filesystem setup. Permission flows are partially covered via the dry-run test for `+apply-permission`; the full permission.members.* API surface remains uncovered.
|
||||
|
||||
## Command Table
|
||||
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✕ | drive +add-comment | shortcut | | none | no comment workflow yet |
|
||||
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
|
||||
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✕ | drive +export | shortcut | | none | no export workflow yet |
|
||||
|
||||
193
tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go
Normal file
193
tests/cli_e2e/drive/drive_apply_permission_dryrun_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_ApplyPermissionDryRun locks in the request shape the shortcut
|
||||
// emits under --dry-run: the real CLI binary is invoked end-to-end (so the
|
||||
// full flag-parsing, validation, and dry-run renderers all execute), and the
|
||||
// printed request is inspected to confirm
|
||||
// - HTTP method, URL template, and the token path segment,
|
||||
// - type query parameter (auto-inferred from a URL input, explicit for a
|
||||
// bare token input),
|
||||
// - perm / remark body fields.
|
||||
//
|
||||
// Fake credentials are sufficient because --dry-run short-circuits before
|
||||
// any network call.
|
||||
func TestDrive_ApplyPermissionDryRun(t *testing.T) {
|
||||
// Isolate from any local CLI state: the subprocess inherits the parent
|
||||
// test environment, and without an explicit config dir it could read a
|
||||
// developer's real credentials/profile instead of the fake ones below.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
wantType string
|
||||
wantPerm string
|
||||
wantBody map[string]string // optional substrings (key=rendered token) to require
|
||||
}{
|
||||
{
|
||||
name: "URL input auto-infers docx type",
|
||||
args: []string{
|
||||
"drive", "+apply-permission",
|
||||
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
|
||||
"--perm", "view",
|
||||
"--remark", "e2e note",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E001/members/apply",
|
||||
wantType: "docx",
|
||||
wantPerm: "view",
|
||||
wantBody: map[string]string{"remark": "e2e note"},
|
||||
},
|
||||
{
|
||||
name: "URL input auto-infers sheet type",
|
||||
args: []string{
|
||||
"drive", "+apply-permission",
|
||||
"--token", "https://example.feishu.cn/sheets/shtcnE2E002?sheet=abc",
|
||||
"--perm", "edit",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/shtcnE2E002/members/apply",
|
||||
wantType: "sheet",
|
||||
wantPerm: "edit",
|
||||
},
|
||||
{
|
||||
// Explicit --type must override URL inference: the /docx/ marker
|
||||
// would infer type=docx, but the caller asked for type=wiki (e.g.
|
||||
// to apply against the underlying wiki node rather than its docx
|
||||
// target). The URL token itself is still used as the path token.
|
||||
name: "explicit --type overrides URL inference",
|
||||
args: []string{
|
||||
"drive", "+apply-permission",
|
||||
"--token", "https://example.feishu.cn/docx/doxcnE2E003",
|
||||
"--type", "wiki",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E003/members/apply",
|
||||
wantType: "wiki",
|
||||
wantPerm: "view",
|
||||
},
|
||||
{
|
||||
name: "bare token with explicit type",
|
||||
args: []string{
|
||||
"drive", "+apply-permission",
|
||||
"--token", "bscE2E004",
|
||||
"--type", "bitable",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/bscE2E004/members/apply",
|
||||
wantType: "bitable",
|
||||
wantPerm: "view",
|
||||
},
|
||||
{
|
||||
name: "slides URL inference",
|
||||
args: []string{
|
||||
"drive", "+apply-permission",
|
||||
"--token", "https://example.feishu.cn/slides/slE2E004",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/slE2E004/members/apply",
|
||||
wantType: "slides",
|
||||
wantPerm: "view",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
// Dry-run output is the JSON envelope; gjson walks into api[0].
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("method = %q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
|
||||
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantType {
|
||||
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.perm").String(); got != tt.wantPerm {
|
||||
t.Fatalf("body.perm = %q, want %q\nstdout:\n%s", got, tt.wantPerm, out)
|
||||
}
|
||||
for k, v := range tt.wantBody {
|
||||
if got := gjson.Get(out, "api.0.body."+k).String(); got != v {
|
||||
t.Fatalf("body.%s = %q, want %q\nstdout:\n%s", k, got, v, out)
|
||||
}
|
||||
}
|
||||
// When no --remark is passed, the body must NOT carry an empty
|
||||
// remark field (the owner's request card would otherwise render
|
||||
// a blank note).
|
||||
if _, wantsRemark := tt.wantBody["remark"]; !wantsRemark {
|
||||
if gjson.Get(out, "api.0.body.remark").Exists() {
|
||||
t.Fatalf("body.remark should be omitted when --remark is empty, stdout:\n%s", out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_ApplyPermissionDryRunRejectsFullAccess locks in the client-side
|
||||
// enum guard: the spec rejects perm=full_access, so the shortcut must refuse
|
||||
// it before the request ever reaches the server. Exercised end-to-end to
|
||||
// guarantee the enum validator is wired into the mount path.
|
||||
func TestDrive_ApplyPermissionDryRunRejectsFullAccess(t *testing.T) {
|
||||
// Isolate from any local CLI state: the subprocess inherits the parent
|
||||
// test environment, and without an explicit config dir it could read a
|
||||
// developer's real credentials/profile instead of the fake ones below.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+apply-permission",
|
||||
"--token", "doxcnE2E999",
|
||||
"--type", "docx",
|
||||
"--perm", "full_access",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("full_access must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "perm") {
|
||||
t.Fatalf("expected perm-related error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user