mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
28 Commits
fix/instal
...
v1.0.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c799d5a9f | ||
|
|
474cb30a48 | ||
|
|
e8e0c6fc5a | ||
|
|
b8f71d50d1 | ||
|
|
46468a900c | ||
|
|
f59f263138 | ||
|
|
51d07be18a | ||
|
|
344ff88701 | ||
|
|
78ff1e7968 | ||
|
|
fa16fe1976 | ||
|
|
d8b0865814 | ||
|
|
d026741532 | ||
|
|
cd7a2363e5 | ||
|
|
353c473e52 | ||
|
|
76fac115ed | ||
|
|
d2a834051d | ||
|
|
d30a9472c3 | ||
|
|
b8fa2b3f80 | ||
|
|
6ec19cbc84 | ||
|
|
d7363b0481 | ||
|
|
5f3915b25c | ||
|
|
4e65ea808e | ||
|
|
d7262b7dc5 | ||
|
|
c16a021ac6 | ||
|
|
fd9ee6afd6 | ||
|
|
69cf9f206e | ||
|
|
99b8aaa556 | ||
|
|
b4a26b2cdc |
66
CHANGELOG.md
66
CHANGELOG.md
@@ -2,6 +2,71 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.8] - 2026-04-10
|
||||
|
||||
### Features
|
||||
|
||||
- Add `update` command with self-update, verification, and rollback (#391)
|
||||
- Add `--file` flag for multipart/form-data file uploads (#395)
|
||||
- Support file comment reply reactions (#380)
|
||||
- **base**: Add `+dashboard-arrange` command for auto-arranging dashboard blocks layout and `text` block type with Markdown support (#388)
|
||||
- **base**: Add record batch `+add` / `+set` shortcuts (#277)
|
||||
- **base**: Add `+record-search` for keyword-based record search (#328)
|
||||
- **base**: Add view visible fields `+get` / `+set` shortcuts (#326)
|
||||
- **base**: Add record field filters (#327)
|
||||
- **base**: Optimize workflow skills (#345)
|
||||
- **calendar**: Add room find workflow (#403)
|
||||
- **mail**: Add `--page-token` and `--page-size` to mail `+triage` (#301)
|
||||
- **whiteboard**: Add `+query` shortcut and enhance `+update` with Mermaid/PlantUML support (#382)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Improve error hints for sandbox and initialization issues (#384)
|
||||
- Fix markdown line breaks support (#338)
|
||||
- Return raw base field and view responses (#378)
|
||||
- **base**: Return raw table list response and clarify sort help (#393)
|
||||
- **calendar**: Add default video meeting to `+create` (#383)
|
||||
- **mail**: Replace `os.Exit` with graceful shutdown in mail watch (#350)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Document Base attachment download via docs `+media-download` (#404)
|
||||
- Reorganize lark-base skill guidance (#374)
|
||||
|
||||
## [v1.0.7] - 2026-04-09
|
||||
|
||||
### Features
|
||||
|
||||
- Auto-grant current user access for bot-created docs, sheets, imports, and uploads (#360)
|
||||
- **mail**: Add `send_as` alias support, mailbox/sender discovery APIs, and mail rules API
|
||||
- **vc**: Extract note doc tokens from calendar event relation API (#333)
|
||||
- **wiki**: Add wiki node create shortcut (#320)
|
||||
- **sheets**: Add `+write-image` shortcut (#343)
|
||||
- **docs**: Add media-preview shortcut (#334)
|
||||
- **docs**: Add support for additional search filters (#353)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api**: Support stdin and quoted JSON inputs on Windows (#367)
|
||||
- **doc**: Post-process `docs +fetch` output to improve round-trip fidelity (#214)
|
||||
- **run**: Add missing binary check for lark-cli execution (#362)
|
||||
- **config**: Validate appId and appSecret keychain key consistency (#295)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Route base import guidance to drive `+import` (#368)
|
||||
- Migrate mail shortcuts to FileIO (#356)
|
||||
- Migrate drive/doc/sheets shortcuts to FileIO (#339)
|
||||
- Migrate base shortcuts to FileIO (#347)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-doc**: Document advanced boolean and intitle search syntax for AI agents (#210)
|
||||
|
||||
### Chore
|
||||
|
||||
- Add depguard and forbidigo rules to guide FileIO adoption (#342)
|
||||
|
||||
## [v1.0.6] - 2026-04-08
|
||||
|
||||
### Features
|
||||
@@ -222,6 +287,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
|
||||
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
|
||||
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
|
||||
[v1.0.4]: https://github.com/larksuite/cli/releases/tag/v1.0.4
|
||||
|
||||
@@ -41,6 +41,7 @@ type APIOptions struct {
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string
|
||||
}
|
||||
|
||||
var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`)
|
||||
@@ -87,6 +88,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
||||
|
||||
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
@@ -105,20 +107,24 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
}
|
||||
|
||||
// buildAPIRequest validates flags and builds a RawApiRequest.
|
||||
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
|
||||
// stdin is an io.Reader consumed at most once. Only one of --params/--data
|
||||
// may use "-" (stdin); the conflict check below prevents silent data loss.
|
||||
// When dryRun is true and a file is provided, file reading is skipped and
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
|
||||
// Validate --file mutual exclusions first.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
}
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if opts.PageSize > 0 {
|
||||
params["page_size"] = opts.PageSize
|
||||
@@ -128,14 +134,53 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
|
||||
Method: opts.Method,
|
||||
URL: normalisePath(opts.Path),
|
||||
Params: params,
|
||||
Data: data,
|
||||
As: opts.As,
|
||||
}
|
||||
// WithFileDownload tells the SDK to skip CodeError parsing on 200 OK.
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
|
||||
if opts.File != "" {
|
||||
// File upload path: build formdata.
|
||||
fieldName, filePath, isStdin := cmdutil.ParseFileFlag(opts.File, "file")
|
||||
|
||||
// Parse --data as JSON map for form fields (not as body).
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
return request, &cmdutil.FileUploadMeta{
|
||||
FieldName: fieldName, FilePath: filePath, FormFields: dataFields,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = fd
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
// Normal path: JSON body.
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = data
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
}
|
||||
}
|
||||
return request, nil
|
||||
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func apiRun(opts *APIOptions) error {
|
||||
@@ -153,7 +198,7 @@ func apiRun(opts *APIOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := buildAPIRequest(opts)
|
||||
request, fileMeta, err := buildAPIRequest(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,6 +209,9 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
}
|
||||
return apiDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
|
||||
@@ -5,6 +5,7 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -706,3 +707,98 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
t.Errorf("expected method POST (uppercased), got %s", gotOpts.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--file", "image=photo.jpg", "--data", `{"image_type":"message"}`})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.File != "image=photo.jpg" {
|
||||
t.Errorf("expected File = %q, got %q", "image=photo.jpg", gotOpts.File)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --file with --output")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected mutual exclusion error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --file with GET")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires POST") {
|
||||
t.Errorf("expected method error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --file stdin with --data stdin")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot both read from stdin") {
|
||||
t.Errorf("expected stdin conflict error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := tmpDir + "/test.jpg"
|
||||
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,18 +134,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
// Expand --domain all to all available domains (from_meta projects + shortcut services)
|
||||
for _, d := range selectedDomains {
|
||||
if strings.EqualFold(d, "all") {
|
||||
domainSet := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
domainSet[p] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
domainSet[sc.Service] = true
|
||||
}
|
||||
selectedDomains = make([]string, 0, len(domainSet))
|
||||
for d := range domainSet {
|
||||
selectedDomains = append(selectedDomains, d)
|
||||
}
|
||||
sort.Strings(selectedDomains)
|
||||
selectedDomains = sortedKnownDomains()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -451,6 +440,8 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
|
||||
|
||||
// collectScopesForDomains collects API scopes (from from_meta projects) and
|
||||
// shortcut scopes for the given domain names.
|
||||
// Domains with auth_domain children are automatically expanded to include
|
||||
// their children's scopes.
|
||||
func collectScopesForDomains(domains []string, identity string) []string {
|
||||
scopeSet := make(map[string]bool)
|
||||
|
||||
@@ -459,11 +450,16 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
|
||||
// 2. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
// 2. Expand domains: include auth_domain children
|
||||
domainSet := make(map[string]bool, len(domains))
|
||||
for _, d := range domains {
|
||||
domainSet[d] = true
|
||||
for _, child := range registry.GetAuthChildren(d) {
|
||||
domainSet[child] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.ScopesForIdentity(identity) {
|
||||
@@ -472,7 +468,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Deduplicate and sort
|
||||
// 4. Deduplicate and sort
|
||||
result := make([]string, 0, len(scopeSet))
|
||||
for s := range scopeSet {
|
||||
result = append(result, s)
|
||||
@@ -481,14 +477,20 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// allKnownDomains returns all valid domain names (from_meta projects + shortcut services).
|
||||
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
||||
// shortcut services), excluding domains that have auth_domain set (they are
|
||||
// folded into their parent domain).
|
||||
func allKnownDomains() map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
domains[p] = true
|
||||
if !registry.HasAuthDomain(p) {
|
||||
domains[p] = true
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
domains[sc.Service] = true
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
}
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
@@ -34,8 +34,12 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
seen := make(map[string]bool)
|
||||
var domains []domainMeta
|
||||
|
||||
// 1. Domains from from_meta projects
|
||||
// 1. Domains from from_meta projects (skip domains with auth_domain)
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
if registry.HasAuthDomain(project) {
|
||||
seen[project] = true
|
||||
continue
|
||||
}
|
||||
dm := buildDomainMeta(project, lang)
|
||||
domains = append(domains, dm)
|
||||
seen[project] = true
|
||||
@@ -52,13 +56,14 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
}
|
||||
|
||||
// 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains
|
||||
// (skip domains with auth_domain — they are folded into their parent)
|
||||
shortcutOnlySet := make(map[string]bool)
|
||||
for _, n := range shortcutOnlyNames {
|
||||
shortcutOnlySet[n] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !seen[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
|
||||
dm := buildDomainMeta(sc.Service, lang)
|
||||
domains = append(domains, dm)
|
||||
}
|
||||
|
||||
@@ -903,3 +903,37 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := allKnownDomains()
|
||||
if domains["whiteboard"] {
|
||||
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
|
||||
}
|
||||
if !domains["docs"] {
|
||||
t.Error("docs should still be a known auth domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"docs"}, "user")
|
||||
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
|
||||
found := false
|
||||
for _, s := range scopes {
|
||||
if strings.HasPrefix(s, "board:whiteboard:") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("collectScopesForDomains([docs]) should include whiteboard scopes (board:whiteboard:*)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
if dm.Name == "whiteboard" {
|
||||
t.Error("whiteboard should not appear in interactive domain list (has auth_domain=docs)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
203
cmd/diagnose_scope_test.go
Normal file
203
cmd/diagnose_scope_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutTypes "github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ── Data types ────────────────────────────────────────────────────────
|
||||
|
||||
type diagMethodEntry struct {
|
||||
Domain string `json:"domain"`
|
||||
Type string `json:"type"` // "api" or "shortcut"
|
||||
Method string `json:"method"` // "calendar.calendars.search" or "+agenda"
|
||||
Scope string `json:"scope"` // minimum-privilege scope
|
||||
Identity []string `json:"identity"` // ["user"], ["bot"], or ["user","bot"]
|
||||
}
|
||||
|
||||
type diagScopeInfo struct {
|
||||
Scope string `json:"scope"`
|
||||
Recommend bool `json:"recommend"`
|
||||
InPriority bool `json:"in_priority"`
|
||||
}
|
||||
|
||||
type diagOutput struct {
|
||||
Methods []diagMethodEntry `json:"methods"`
|
||||
Scopes []diagScopeInfo `json:"scopes"`
|
||||
}
|
||||
|
||||
// ── Core logic ────────────────────────────────────────────────────────
|
||||
|
||||
// diagAllKnownDomains returns sorted, deduplicated domain names from both
|
||||
// from_meta projects and shortcuts.
|
||||
func diagAllKnownDomains() []string {
|
||||
seen := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
seen[p] = true
|
||||
}
|
||||
for _, s := range shortcuts.AllShortcuts() {
|
||||
if s.Service != "" {
|
||||
seen[s.Service] = true
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
for d := range seen {
|
||||
result = append(result, d)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// methodKey uniquely identifies a method+scope pair for merging identities.
|
||||
type methodKey struct {
|
||||
domain string
|
||||
typ string
|
||||
method string
|
||||
scope string
|
||||
}
|
||||
|
||||
// diagBuild builds the full output: flat methods list (merged identities) + scopes.
|
||||
func diagBuild(domains []string) diagOutput {
|
||||
recommend := registry.LoadAutoApproveSet()
|
||||
identities := []string{"user", "bot"}
|
||||
|
||||
merged := make(map[methodKey]*diagMethodEntry)
|
||||
allSC := shortcuts.AllShortcuts()
|
||||
|
||||
for _, domain := range domains {
|
||||
for _, identity := range identities {
|
||||
for _, ce := range registry.CollectCommandScopes([]string{domain}, identity) {
|
||||
for _, scope := range ce.Scopes {
|
||||
method := domain + "." + strings.ReplaceAll(ce.Command, " ", ".")
|
||||
k := methodKey{domain, "api", method, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
} else {
|
||||
merged[k] = &diagMethodEntry{
|
||||
Domain: domain, Type: "api",
|
||||
Method: method,
|
||||
Scope: scope, Identity: []string{identity},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, sc := range allSC {
|
||||
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
|
||||
continue
|
||||
}
|
||||
for _, scope := range sc.ScopesForIdentity(identity) {
|
||||
k := methodKey{domain, "shortcut", sc.Command, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
} else {
|
||||
merged[k] = &diagMethodEntry{
|
||||
Domain: domain, Type: "shortcut",
|
||||
Method: sc.Command,
|
||||
Scope: scope, Identity: []string{identity},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
methods := make([]diagMethodEntry, 0, len(merged))
|
||||
scopeSet := make(map[string]bool)
|
||||
for _, e := range merged {
|
||||
methods = append(methods, *e)
|
||||
scopeSet[e.Scope] = true
|
||||
}
|
||||
sort.Slice(methods, func(i, j int) bool {
|
||||
if methods[i].Domain != methods[j].Domain {
|
||||
return methods[i].Domain < methods[j].Domain
|
||||
}
|
||||
if methods[i].Type != methods[j].Type {
|
||||
return methods[i].Type < methods[j].Type
|
||||
}
|
||||
if methods[i].Method != methods[j].Method {
|
||||
return methods[i].Method < methods[j].Method
|
||||
}
|
||||
return methods[i].Scope < methods[j].Scope
|
||||
})
|
||||
|
||||
scopeList := make([]string, 0, len(scopeSet))
|
||||
for s := range scopeSet {
|
||||
scopeList = append(scopeList, s)
|
||||
}
|
||||
sort.Strings(scopeList)
|
||||
|
||||
priorities := registry.LoadScopePriorities()
|
||||
scopes := make([]diagScopeInfo, len(scopeList))
|
||||
for i, s := range scopeList {
|
||||
_, inPri := priorities[s]
|
||||
scopes[i] = diagScopeInfo{Scope: s, Recommend: recommend[s], InPriority: inPri}
|
||||
}
|
||||
|
||||
return diagOutput{Methods: methods, Scopes: scopes}
|
||||
}
|
||||
|
||||
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
|
||||
if len(sc.AuthTypes) == 0 {
|
||||
return identity == "user"
|
||||
}
|
||||
for _, a := range sc.AuthTypes {
|
||||
if a == identity {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func appendUniq(ss []string, s string) []string {
|
||||
for _, existing := range ss {
|
||||
if existing == s {
|
||||
return ss
|
||||
}
|
||||
}
|
||||
return append(ss, s)
|
||||
}
|
||||
|
||||
// ── Snapshot generation ───────────────────────────────────────────────
|
||||
//
|
||||
// Generates a JSON snapshot of all API methods and shortcuts with their
|
||||
// minimum-privilege scopes. Consumed by scripts/scope_audit.py.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// SCOPE_SNAPSHOT_DIR=/tmp/scope-audit go test ./cmd/ -run TestScopeSnapshot -v
|
||||
func TestScopeSnapshot(t *testing.T) {
|
||||
dir := os.Getenv("SCOPE_SNAPSHOT_DIR")
|
||||
if dir == "" {
|
||||
t.Skip("set SCOPE_SNAPSHOT_DIR to enable snapshot generation")
|
||||
}
|
||||
|
||||
registry.Init()
|
||||
result := diagBuild(diagAllKnownDomains())
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "snapshot.json")
|
||||
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Wrote %s (%d methods, %d scopes)", path, len(result.Methods), len(result.Scopes))
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func checkCLIUpdate() []checkResult {
|
||||
if update.IsNewer(latest, current) {
|
||||
return []checkResult{warn("cli_update",
|
||||
fmt.Sprintf("%s → %s available", current, latest),
|
||||
"run: npm update -g @larksuite/cli")}
|
||||
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
|
||||
}
|
||||
return []checkResult{pass("cli_update", latest+" (up to date)")}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -118,6 +119,7 @@ func Execute() int {
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
|
||||
|
||||
@@ -73,6 +73,12 @@ func printResourceList(w io.Writer, spec map[string]interface{}) {
|
||||
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
|
||||
}
|
||||
|
||||
// hasFileFields returns true if any requestBody field has type "file".
|
||||
func hasFileFields(method map[string]interface{}) (bool, []string) {
|
||||
names := cmdutil.DetectFileFields(method)
|
||||
return len(names) > 0, names
|
||||
}
|
||||
|
||||
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
@@ -80,6 +86,7 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
|
||||
fullPath := servicePath + "/" + methodPath
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
isFileUpload, fileFieldNames := hasFileFields(method)
|
||||
|
||||
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
|
||||
|
||||
@@ -138,11 +145,25 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
|
||||
if len(params) == 0 {
|
||||
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %s--data%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fileUploadTag := ""
|
||||
if isFileUpload {
|
||||
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
|
||||
requestBody, _ := method["requestBody"].(map[string]interface{})
|
||||
if len(requestBody) > 0 {
|
||||
printNestedFields(w, requestBody, " ", "")
|
||||
}
|
||||
|
||||
if isFileUpload {
|
||||
if len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
|
||||
} else {
|
||||
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
@@ -184,7 +205,13 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
|
||||
}
|
||||
|
||||
// CLI example
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
if isFileUpload && len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else if isFileUpload {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
}
|
||||
|
||||
// Docs
|
||||
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -61,3 +62,123 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
|
||||
t.Errorf("expected 'Unknown service' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_FileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"description": "Upload an image",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image_type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "images", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "file upload") {
|
||||
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file") {
|
||||
t.Errorf("expected '--file' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"image"`) {
|
||||
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file <path>") {
|
||||
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "calendar",
|
||||
"servicePath": "/open-apis/calendar/v4",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "events",
|
||||
"httpMethod": "POST",
|
||||
"description": "Create an event",
|
||||
"requestBody": map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "events", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, "file upload") {
|
||||
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "--file") {
|
||||
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFileFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method map[string]interface{}
|
||||
wantBool bool
|
||||
wantFields []string
|
||||
}{
|
||||
{
|
||||
name: "has file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{"type": "file"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: true,
|
||||
wantFields: []string{"image"},
|
||||
},
|
||||
{
|
||||
name: "no file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
{
|
||||
name: "no requestBody",
|
||||
method: map[string]interface{}{},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, names := hasFileFields(tt.method)
|
||||
if got != tt.wantBool {
|
||||
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
|
||||
}
|
||||
if tt.wantFields == nil && names != nil {
|
||||
t.Errorf("expected nil names, got %v", names)
|
||||
}
|
||||
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
|
||||
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,13 @@ type ServiceMethodOptions struct {
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string // --file flag value
|
||||
FileFields []string // auto-detected file field names from metadata
|
||||
}
|
||||
|
||||
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
|
||||
func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
}
|
||||
|
||||
func registerMethod(parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
@@ -161,6 +168,16 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
|
||||
// Conditionally register --file for methods with file-type fields.
|
||||
fileFields := detectFileFields(method)
|
||||
opts.FileFields = fileFields
|
||||
if len(fileFields) > 0 {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
}
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
@@ -212,12 +229,15 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
request, err := buildServiceRequest(opts)
|
||||
request, fileMeta, err := buildServiceRequest(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
}
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
|
||||
@@ -303,7 +323,9 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
}
|
||||
|
||||
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
||||
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, error) {
|
||||
// When dryRun is true and a file is provided, file reading is skipped and
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
spec := opts.Spec
|
||||
method := opts.Method
|
||||
schemaPath := opts.SchemaPath
|
||||
@@ -312,12 +334,17 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
// stdin is an io.Reader consumed at most once. Only one of --params/--data
|
||||
// may use "-" (stdin); the conflict check below prevents silent data loss.
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
|
||||
// Validate --file mutual exclusions.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
|
||||
@@ -330,13 +357,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
}
|
||||
val, ok := params[name]
|
||||
if !ok || util.IsEmptyValue(val) {
|
||||
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("missing required path parameter: %s", name),
|
||||
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if err := validate.ResourceName(valStr, name); err != nil {
|
||||
return client.RawApiRequest{}, output.ErrValidation("%s", err)
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("%s", err)
|
||||
}
|
||||
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
|
||||
delete(params, name)
|
||||
@@ -352,7 +379,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
required, _ := p["required"].(bool)
|
||||
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
|
||||
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
|
||||
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("missing required query parameter: %s", name),
|
||||
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
||||
}
|
||||
@@ -366,22 +393,60 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
}
|
||||
}
|
||||
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
}
|
||||
|
||||
request := client.RawApiRequest{
|
||||
Method: httpMethod,
|
||||
URL: url,
|
||||
Params: queryParams,
|
||||
Data: data,
|
||||
As: opts.As,
|
||||
}
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
|
||||
if opts.File != "" {
|
||||
// File upload: determine default field name from metadata.
|
||||
defaultField := "file"
|
||||
if len(opts.FileFields) == 1 {
|
||||
defaultField = opts.FileFields[0]
|
||||
}
|
||||
fieldName, filePath, isStdin := cmdutil.ParseFileFlag(opts.File, defaultField)
|
||||
|
||||
// Parse --data as form fields.
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
return request, &cmdutil.FileUploadMeta{
|
||||
FieldName: fieldName, FilePath: filePath, FormFields: dataFields,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = fd
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = data
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
}
|
||||
}
|
||||
return request, nil
|
||||
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -710,6 +711,144 @@ func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── file upload ──
|
||||
|
||||
func imImageMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image_type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
}
|
||||
}
|
||||
|
||||
func imSpec() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --file flag to be registered for file upload method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagNotRegistered(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag != nil {
|
||||
t.Fatal("expected --file flag NOT to be registered for non-file method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
|
||||
getMethod := map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "GET",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
},
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag != nil {
|
||||
t.Fatal("expected --file flag NOT to be registered for GET method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := tmpDir + "/test.jpg"
|
||||
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--file", "image=" + tmpFile,
|
||||
"--data", `{"image_type":"message"}`,
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFileFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method map[string]interface{}
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "single file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{"type": "file"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
want: []string{"image"},
|
||||
},
|
||||
{
|
||||
name: "no file fields",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "no requestBody",
|
||||
method: map[string]interface{}{},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectFileFields(tt.method)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("detectFileFields()[%d] = %q, want %q", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
func isExitError(err error, target **output.ExitError) bool {
|
||||
|
||||
314
cmd/update/update.go
Normal file
314
cmd/update/update.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
const (
|
||||
repoURL = "https://github.com/larksuite/cli"
|
||||
maxNpmOutput = 2000
|
||||
osWindows = "windows"
|
||||
)
|
||||
|
||||
// Overridable for testing.
|
||||
var (
|
||||
fetchLatest = func() (string, error) { return update.FetchLatest() }
|
||||
currentVersion = func() string { return build.Version }
|
||||
currentOS = runtime.GOOS
|
||||
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
|
||||
)
|
||||
|
||||
func isWindows() bool { return currentOS == osWindows }
|
||||
|
||||
func releaseURL(version string) string {
|
||||
return repoURL + "/releases/tag/v" + strings.TrimPrefix(version, "v")
|
||||
}
|
||||
|
||||
func changelogURL() string { return repoURL + "/blob/main/CHANGELOG.md" }
|
||||
|
||||
// --- Terminal symbols (ASCII fallback on Windows) ---
|
||||
|
||||
func symOK() string {
|
||||
if isWindows() {
|
||||
return "[OK]"
|
||||
}
|
||||
return "✓"
|
||||
}
|
||||
|
||||
func symFail() string {
|
||||
if isWindows() {
|
||||
return "[FAIL]"
|
||||
}
|
||||
return "✗"
|
||||
}
|
||||
|
||||
func symWarn() string {
|
||||
if isWindows() {
|
||||
return "[WARN]"
|
||||
}
|
||||
return "⚠"
|
||||
}
|
||||
|
||||
func symArrow() string {
|
||||
if isWindows() {
|
||||
return "->"
|
||||
}
|
||||
return "→"
|
||||
}
|
||||
|
||||
// --- Command ---
|
||||
|
||||
// UpdateOptions holds inputs for the update command.
|
||||
type UpdateOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
}
|
||||
|
||||
// NewCmdUpdate creates the update command.
|
||||
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &UpdateOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Update lark-cli to the latest version",
|
||||
Long: `Update lark-cli to the latest version.
|
||||
|
||||
Detects the installation method automatically:
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- manual/other: shows GitHub Releases download URL
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
Use --check to only check for updates without installing.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return updateRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
|
||||
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func updateRun(opts *UpdateOptions) error {
|
||||
io := opts.Factory.IOStreams
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
updater.CleanupStaleFiles()
|
||||
output.PendingNotice = nil
|
||||
|
||||
// 1. Fetch latest version
|
||||
latest, err := fetchLatest()
|
||||
if err != nil {
|
||||
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
|
||||
}
|
||||
|
||||
// 2. Validate version format
|
||||
if update.ParseVersion(latest) == nil {
|
||||
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
|
||||
}
|
||||
|
||||
// 3. Compare versions
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
"latest_version": latest, "action": "already_up_to_date",
|
||||
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "%s lark-cli %s is already up to date\n", symOK(), cur)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. Detect installation method
|
||||
detect := updater.DetectInstallMethod()
|
||||
|
||||
// 5. --check
|
||||
if opts.Check {
|
||||
return reportCheckResult(opts, io, cur, latest, detect.CanAutoUpdate())
|
||||
}
|
||||
|
||||
// 6. Execute update
|
||||
if !detect.CanAutoUpdate() {
|
||||
return doManualUpdate(opts, io, cur, latest, detect)
|
||||
}
|
||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
||||
}
|
||||
|
||||
// --- Output helpers ---
|
||||
|
||||
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
|
||||
})
|
||||
return output.ErrBare(exitCode)
|
||||
}
|
||||
return output.Errorf(exitCode, errType, "%s", msg)
|
||||
}
|
||||
|
||||
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
"latest_version": latest, "action": "update_available",
|
||||
"auto_update": canAutoUpdate,
|
||||
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "Update available: %s %s %s\n", cur, symArrow(), latest)
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
if canAutoUpdate {
|
||||
fmt.Fprintf(io.ErrOut, "\nRun `lark-cli update` to install.\n")
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "\nDownload the release above to update manually.\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult) error {
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "latest_version": latest,
|
||||
"action": "manual_required",
|
||||
"message": fmt.Sprintf("Automatic update unavailable: %s (path: %s)", reason, detect.ResolvedPath),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "Automatic update unavailable: %s (path: %s).\n\n", reason, detect.ResolvedPath)
|
||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
|
||||
fmt.Fprintf(io.ErrOut, "\nAfter updating, also update skills:\n npx -y skills add larksuite/cli -g -y\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
||||
restore, err := updater.PrepareSelfReplace()
|
||||
if err != nil {
|
||||
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
|
||||
}
|
||||
|
||||
if !opts.JSON {
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
||||
}
|
||||
|
||||
npmResult := updater.RunNpmInstall(latest)
|
||||
if npmResult.Err != nil {
|
||||
restore()
|
||||
combined := npmResult.CombinedOutput()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{
|
||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||
"hint": permissionHint(combined),
|
||||
},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
if npmResult.Stdout.Len() > 0 {
|
||||
fmt.Fprint(io.ErrOut, npmResult.Stdout.String())
|
||||
}
|
||||
if npmResult.Stderr.Len() > 0 {
|
||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||
if hint := permissionHint(combined); hint != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
}
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
// Verify the new binary is functional before proceeding.
|
||||
// If corrupt, restore the previous version from .old.
|
||||
if err := updater.VerifyBinary(latest); err != nil {
|
||||
restore()
|
||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||
hint := verificationFailureHint(updater, latest)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": map[string]interface{}{"type": "update_error", "message": msg, "hint": hint},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s %s\n", symFail(), msg)
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
// Skills update (best-effort).
|
||||
skillsResult := updater.RunSkillsUpdate()
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": latest,
|
||||
"latest_version": latest, "action": "updated",
|
||||
"message": fmt.Sprintf("lark-cli updated from %s to %s", cur, latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
}
|
||||
if skillsResult.Err != nil {
|
||||
result["skills_warning"] = fmt.Sprintf("skills update failed: %s", skillsResult.Err)
|
||||
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
|
||||
result["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
|
||||
}
|
||||
}
|
||||
output.PrintJson(io.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||
if skillsResult.Err != nil {
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %s\n", symWarn(), skillsResult.Err)
|
||||
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, 500))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionHint(npmOutput string) string {
|
||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
||||
if updater.CanRestorePreviousVersion() {
|
||||
return "the previous version has been restored"
|
||||
}
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually: npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
851
cmd/update/update_test.go
Normal file
851
cmd/update/update_test.go
Normal file
@@ -0,0 +1,851 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
)
|
||||
|
||||
// newTestFactory creates a test factory with minimal config.
|
||||
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
|
||||
npmFn func(string) *selfupdate.NpmResult,
|
||||
skillsFn func() *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.NpmInstallOverride = npmFn
|
||||
u.SkillsUpdateOverride = skillsFn
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "already_up_to_date"`) {
|
||||
t.Errorf("expected already_up_to_date in JSON output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"ok": true`) {
|
||||
t.Errorf("expected ok:true in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "already up to date") {
|
||||
t.Errorf("expected 'already up to date' in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateManual_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "manual_required"`) {
|
||||
t.Errorf("expected manual_required in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "not installed via npm") {
|
||||
t.Errorf("expected accurate reason in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "releases/tag/v2.0.0") {
|
||||
t.Errorf("expected version-pinned URL in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateManual_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "not installed via npm") {
|
||||
t.Errorf("expected 'not installed via npm' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "releases/tag/v2.0.0") {
|
||||
t.Errorf("expected version-pinned URL in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--force", "--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFetchError_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "", errors.New("network timeout") }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
err := cmd.Execute()
|
||||
// cobra silences errors when RunE returns; we just check stdout
|
||||
_ = err
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"ok": false`) {
|
||||
t.Errorf("expected ok:false in JSON output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "network timeout") {
|
||||
t.Errorf("expected 'network timeout' in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFetchError_Human(t *testing.T) {
|
||||
f, _, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "", errors.New("network timeout") }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
// Suppress cobra's default error printing.
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInvalidVersion_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "not-a-version", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
_ = cmd.Execute()
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "invalid version") {
|
||||
t.Errorf("expected 'invalid version' in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "DEV" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmFail_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
fmt.Fprint(&r.Stderr, "EACCES: permission denied")
|
||||
r.Err = errors.New("npm install failed")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
_ = cmd.Execute()
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "permission denied") {
|
||||
t.Errorf("expected 'permission denied' in JSON output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"hint"`) {
|
||||
t.Errorf("expected 'hint' field in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmFail_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
fmt.Fprint(&r.Stderr, "EACCES: permission denied")
|
||||
r.Err = errors.New("npm install failed")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
_ = cmd.Execute()
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Update failed") {
|
||||
t.Errorf("expected 'Update failed' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Permission denied") {
|
||||
t.Errorf("expected permission hint in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||
u.RestoreAvailableOverride = func() bool { return false }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
t.Fatal("skills update should not run when binary verification fails")
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected verification failure")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "automatic rollback is unavailable") {
|
||||
t.Errorf("expected unavailable rollback hint, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "previous version has been restored") {
|
||||
t.Errorf("should not claim restore when no backup is available, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npm install -g @larksuite/cli@2.0.0") {
|
||||
t.Errorf("expected manual reinstall command in hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_JSON_Npm(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json", "--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "update_available"`) {
|
||||
t.Errorf("expected update_available action, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"auto_update": true`) {
|
||||
t.Errorf("expected auto_update:true for npm, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "releases/tag/v2.0.0") {
|
||||
t.Errorf("expected version-pinned release URL, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "CHANGELOG") {
|
||||
t.Errorf("expected changelog URL, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_Human_Npm(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Update available") {
|
||||
t.Errorf("expected 'Update available' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "lark-cli update") {
|
||||
t.Errorf("expected 'lark-cli update' instruction for npm, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_Human_Manual(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Update available") {
|
||||
t.Errorf("expected 'Update available' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "manually") {
|
||||
t.Errorf("expected manual download instruction for non-npm, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "lark-cli update` to install") {
|
||||
t.Errorf("should NOT suggest 'lark-cli update' for manual install, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmNotFound_FallsBackToManual(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
// npm detected (node_modules in path) but npm binary not available
|
||||
mockDetect(t, selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallNpm,
|
||||
ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli",
|
||||
NpmAvailable: false,
|
||||
})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "manual_required"`) {
|
||||
t.Errorf("expected manual_required when npm not found, got: %s", out)
|
||||
}
|
||||
// Must say "npm is not available", not generic "not installed via npm"
|
||||
if !strings.Contains(out, "npm is not available") {
|
||||
t.Errorf("expected 'npm is not available' reason when npm detected but missing, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseURL(t *testing.T) {
|
||||
got := releaseURL("2.0.0")
|
||||
if got != "https://github.com/larksuite/cli/releases/tag/v2.0.0" {
|
||||
t.Errorf("expected version-pinned URL, got: %s", got)
|
||||
}
|
||||
got2 := releaseURL("v1.5.0")
|
||||
if got2 != "https://github.com/larksuite/cli/releases/tag/v1.5.0" {
|
||||
t.Errorf("expected no double v prefix, got: %s", got2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionHint(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
||||
currentOS = "linux"
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
||||
if !strings.Contains(hint, "npm global prefix") {
|
||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||
}
|
||||
if strings.Contains(hint, "sudo npm install -g") {
|
||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||
}
|
||||
|
||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||
currentOS = "windows"
|
||||
hint = permissionHint("EACCES: permission denied")
|
||||
if hint != "" {
|
||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||
}
|
||||
|
||||
// Non-EACCES error: always empty.
|
||||
currentOS = "linux"
|
||||
if got := permissionHint("some other error"); got != "" {
|
||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
// With the rename trick, Windows npm installs can now auto-update.
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
origOS := currentOS
|
||||
currentOS = osWindows
|
||||
defer func() { currentOS = origOS }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated on Windows with rename trick, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWindows_Check_JSON(t *testing.T) {
|
||||
// --check on Windows npm should report auto_update: true (rename trick available).
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json", "--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
origOS := currentOS
|
||||
currentOS = osWindows
|
||||
defer func() { currentOS = origOS }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"auto_update": true`) {
|
||||
t.Errorf("expected auto_update:true on Windows (rename trick), got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWindows_Symbols(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
currentOS = "windows"
|
||||
if symOK() != "[OK]" {
|
||||
t.Errorf("expected [OK] on Windows, got: %s", symOK())
|
||||
}
|
||||
if symFail() != "[FAIL]" {
|
||||
t.Errorf("expected [FAIL] on Windows, got: %s", symFail())
|
||||
}
|
||||
if symWarn() != "[WARN]" {
|
||||
t.Errorf("expected [WARN] on Windows, got: %s", symWarn())
|
||||
}
|
||||
if symArrow() != "->" {
|
||||
t.Errorf("expected -> on Windows, got: %s", symArrow())
|
||||
}
|
||||
|
||||
currentOS = "darwin"
|
||||
if symOK() != "\u2713" {
|
||||
t.Errorf("expected \u2713 on darwin, got: %s", symOK())
|
||||
}
|
||||
if symArrow() != "\u2192" {
|
||||
t.Errorf("expected \u2192 on darwin, got: %s", symArrow())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Should NOT have skills_warning when skills succeed
|
||||
if strings.Contains(out, "skills_warning") {
|
||||
t.Errorf("expected no skills_warning on success, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
// Skills update fails
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// CLI update should still succeed (ok:true)
|
||||
if !strings.Contains(out, `"ok": true`) {
|
||||
t.Errorf("expected ok:true despite skills failure, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected action:updated despite skills failure, got: %s", out)
|
||||
}
|
||||
// Should have skills_warning with detail
|
||||
if !strings.Contains(out, "skills_warning") {
|
||||
t.Errorf("expected skills_warning in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "skills_detail") {
|
||||
t.Errorf("expected skills_detail in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
// CLI update should still show success
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected CLI success message, got: %s", out)
|
||||
}
|
||||
// Skills warning should be shown
|
||||
if !strings.Contains(out, "Skills update failed") {
|
||||
t.Errorf("expected skills failure warning, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npx -y skills add") {
|
||||
t.Errorf("expected manual skills command hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
long := strings.Repeat("x", 3000)
|
||||
got := selfupdate.Truncate(long, 2000)
|
||||
if len(got) != 2000 {
|
||||
t.Errorf("expected truncated length 2000, got %d", len(got))
|
||||
}
|
||||
|
||||
short := "hello"
|
||||
got2 := selfupdate.Truncate(short, 2000)
|
||||
if got2 != "hello" {
|
||||
t.Errorf("expected 'hello', got %q", got2)
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,51 @@ func encodeParams(params map[string]interface{}) string {
|
||||
return vals.Encode()
|
||||
}
|
||||
|
||||
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
|
||||
// Instead of serializing the Formdata body, it shows file metadata.
|
||||
func PrintDryRunWithFile(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format, fileField, filePath string, formFields any) error {
|
||||
dr := NewDryRunAPI()
|
||||
switch request.Method {
|
||||
case "POST":
|
||||
dr.POST(request.URL)
|
||||
case "PUT":
|
||||
dr.PUT(request.URL)
|
||||
case "PATCH":
|
||||
dr.PATCH(request.URL)
|
||||
case "DELETE":
|
||||
dr.DELETE(request.URL)
|
||||
default:
|
||||
dr.GET(request.URL)
|
||||
}
|
||||
if len(request.Params) > 0 {
|
||||
dr.Params(request.Params)
|
||||
}
|
||||
filePathDisplay := filePath
|
||||
if filePathDisplay == "" {
|
||||
filePathDisplay = "<stdin>"
|
||||
}
|
||||
fileInfo := map[string]any{
|
||||
"file": map[string]string{"field": fileField, "path": filePathDisplay},
|
||||
}
|
||||
if formFields != nil {
|
||||
fileInfo["form_fields"] = formFields
|
||||
}
|
||||
fileInfo["options"] = []string{"WithFileUpload"}
|
||||
dr.Body(fileInfo)
|
||||
dr.Set("as", string(request.As))
|
||||
dr.Set("appId", config.AppID)
|
||||
if config.UserOpenId != "" {
|
||||
dr.Set("userOpenId", config.UserOpenId)
|
||||
}
|
||||
fmt.Fprintln(w, "=== Dry Run ===")
|
||||
if format == "pretty" {
|
||||
fmt.Fprint(w, dr.Format())
|
||||
} else {
|
||||
output.PrintJson(w, dr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
|
||||
// When format is "pretty", outputs human-readable text; otherwise JSON.
|
||||
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
|
||||
130
internal/cmdutil/fileupload.go
Normal file
130
internal/cmdutil/fileupload.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// DetectFileFields returns field names with type "file" in the method's requestBody.
|
||||
func DetectFileFields(method map[string]interface{}) []string {
|
||||
rb, _ := method["requestBody"].(map[string]interface{})
|
||||
var fields []string
|
||||
for name, field := range rb {
|
||||
f, _ := field.(map[string]interface{})
|
||||
if registry.GetStrFromMap(f, "type") == "file" {
|
||||
fields = append(fields, name)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// ParseFileFlag parses a --file flag value into its components.
|
||||
// The format is either "path" or "field=path". When no explicit "field="
|
||||
// prefix is present, defaultField is used as the field name.
|
||||
// A path of "-" indicates stdin; in that case filePath is empty and isStdin is true.
|
||||
func ParseFileFlag(raw, defaultField string) (fieldName, filePath string, isStdin bool) {
|
||||
if idx := strings.IndexByte(raw, '='); idx > 0 {
|
||||
fieldName = raw[:idx]
|
||||
filePath = raw[idx+1:]
|
||||
} else {
|
||||
fieldName = defaultField
|
||||
filePath = raw
|
||||
}
|
||||
if filePath == "-" {
|
||||
return fieldName, "", true
|
||||
}
|
||||
return fieldName, filePath, false
|
||||
}
|
||||
|
||||
// ValidateFileFlag checks mutual exclusion rules for the --file flag.
|
||||
// Returns nil if file is empty (flag not provided).
|
||||
func ValidateFileFlag(file, params, data, outputPath string, pageAll bool, httpMethod string) error {
|
||||
if file == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, filePath, isStdin := ParseFileFlag(file, "file")
|
||||
if !isStdin && filePath == "" {
|
||||
return output.ErrValidation("--file: empty file path")
|
||||
}
|
||||
|
||||
if outputPath != "" {
|
||||
return output.ErrValidation("--file and --output are mutually exclusive")
|
||||
}
|
||||
if pageAll {
|
||||
return output.ErrValidation("--file and --page-all are mutually exclusive")
|
||||
}
|
||||
if isStdin && data == "-" {
|
||||
return output.ErrValidation("--file and --data cannot both read from stdin")
|
||||
}
|
||||
if isStdin && params == "-" {
|
||||
return output.ErrValidation("--file and --params cannot both read from stdin")
|
||||
}
|
||||
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
default:
|
||||
return output.ErrValidation("--file requires POST, PUT, PATCH, or DELETE method")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileUploadMeta holds file upload metadata for dry-run display.
|
||||
// Returned by request builders when dry-run mode skips actual file reading.
|
||||
type FileUploadMeta struct {
|
||||
FieldName string
|
||||
FilePath string
|
||||
FormFields any
|
||||
}
|
||||
|
||||
// BuildFormdata constructs a multipart form data payload for file upload.
|
||||
// If isStdin is true, the file content is read from stdin.
|
||||
// Top-level keys from dataJSON are added as text form fields.
|
||||
func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin bool, stdin io.Reader, dataJSON any) (*larkcore.Formdata, error) {
|
||||
fd := larkcore.NewFormdata()
|
||||
|
||||
if isStdin {
|
||||
if stdin == nil {
|
||||
return nil, output.ErrValidation("--file: stdin is not available")
|
||||
}
|
||||
data, err := io.ReadAll(stdin)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("--file: failed to read stdin: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, output.ErrValidation("--file: stdin is empty")
|
||||
}
|
||||
fd.AddFile(fieldName, bytes.NewReader(data))
|
||||
} else {
|
||||
f, err := fileIO.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("cannot open file: %s", filePath)
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("--file: failed to read %s: %v", filePath, err)
|
||||
}
|
||||
fd.AddFile(fieldName, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// Add top-level JSON keys as text form fields.
|
||||
if m, ok := dataJSON.(map[string]any); ok {
|
||||
for k, v := range m {
|
||||
fd.AddField(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
return fd, nil
|
||||
}
|
||||
338
internal/cmdutil/fileupload_test.go
Normal file
338
internal/cmdutil/fileupload_test.go
Normal file
@@ -0,0 +1,338 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
func TestParseFileFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
defaultField string
|
||||
wantField string
|
||||
wantPath string
|
||||
wantStdin bool
|
||||
}{
|
||||
{
|
||||
name: "simple filename uses default field",
|
||||
raw: "photo.jpg",
|
||||
defaultField: "file",
|
||||
wantField: "file",
|
||||
wantPath: "photo.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
{
|
||||
name: "simple filename with custom default",
|
||||
raw: "photo.jpg",
|
||||
defaultField: "image",
|
||||
wantField: "image",
|
||||
wantPath: "photo.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
{
|
||||
name: "explicit field prefix",
|
||||
raw: "image=photo.jpg",
|
||||
defaultField: "file",
|
||||
wantField: "image",
|
||||
wantPath: "photo.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
{
|
||||
name: "stdin bare",
|
||||
raw: "-",
|
||||
defaultField: "file",
|
||||
wantField: "file",
|
||||
wantPath: "",
|
||||
wantStdin: true,
|
||||
},
|
||||
{
|
||||
name: "stdin with field prefix",
|
||||
raw: "image=-",
|
||||
defaultField: "file",
|
||||
wantField: "image",
|
||||
wantPath: "",
|
||||
wantStdin: true,
|
||||
},
|
||||
{
|
||||
name: "path with equals sign (only first equals splits)",
|
||||
raw: "field=path/to/file=1.jpg",
|
||||
defaultField: "file",
|
||||
wantField: "field",
|
||||
wantPath: "path/to/file=1.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
{
|
||||
name: "absolute path no prefix",
|
||||
raw: "/tmp/photo.jpg",
|
||||
defaultField: "file",
|
||||
wantField: "file",
|
||||
wantPath: "/tmp/photo.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
{
|
||||
name: "absolute path with field prefix",
|
||||
raw: "image=/tmp/photo.jpg",
|
||||
defaultField: "file",
|
||||
wantField: "image",
|
||||
wantPath: "/tmp/photo.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
{
|
||||
name: "empty field prefix falls through to default",
|
||||
raw: "=photo.jpg",
|
||||
defaultField: "file",
|
||||
wantField: "file",
|
||||
wantPath: "=photo.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
field, path, isStdin := ParseFileFlag(tt.raw, tt.defaultField)
|
||||
if field != tt.wantField {
|
||||
t.Errorf("field = %q, want %q", field, tt.wantField)
|
||||
}
|
||||
if path != tt.wantPath {
|
||||
t.Errorf("path = %q, want %q", path, tt.wantPath)
|
||||
}
|
||||
if isStdin != tt.wantStdin {
|
||||
t.Errorf("isStdin = %v, want %v", isStdin, tt.wantStdin)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFileFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
params string
|
||||
data string
|
||||
outputPath string
|
||||
pageAll bool
|
||||
httpMethod string
|
||||
wantErr string // empty means no error
|
||||
}{
|
||||
{
|
||||
name: "empty file is valid",
|
||||
file: "",
|
||||
httpMethod: "GET",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "empty file path",
|
||||
file: "field=",
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file: empty file path",
|
||||
},
|
||||
{
|
||||
name: "file with output",
|
||||
file: "photo.jpg",
|
||||
outputPath: "out.json",
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file and --output are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "file with page-all",
|
||||
file: "photo.jpg",
|
||||
pageAll: true,
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file and --page-all are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "stdin file with stdin data",
|
||||
file: "-",
|
||||
data: "-",
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file and --data cannot both read from stdin",
|
||||
},
|
||||
{
|
||||
name: "stdin file with stdin params",
|
||||
file: "-",
|
||||
params: "-",
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file and --params cannot both read from stdin",
|
||||
},
|
||||
{
|
||||
name: "file with GET method",
|
||||
file: "photo.jpg",
|
||||
httpMethod: "GET",
|
||||
wantErr: "--file requires POST, PUT, PATCH, or DELETE method",
|
||||
},
|
||||
{
|
||||
name: "file with POST method",
|
||||
file: "photo.jpg",
|
||||
httpMethod: "POST",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "file with PUT method",
|
||||
file: "photo.jpg",
|
||||
httpMethod: "PUT",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "file with PATCH method",
|
||||
file: "photo.jpg",
|
||||
httpMethod: "PATCH",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "file with DELETE method",
|
||||
file: "photo.jpg",
|
||||
httpMethod: "DELETE",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "stdin with field prefix and data stdin",
|
||||
file: "image=-",
|
||||
data: "-",
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file and --data cannot both read from stdin",
|
||||
},
|
||||
{
|
||||
name: "stdin with field prefix and params stdin",
|
||||
file: "image=-",
|
||||
params: "-",
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file and --params cannot both read from stdin",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateFileFlag(tt.file, tt.params, tt.data, tt.outputPath, tt.pageAll, tt.httpMethod)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFormdata(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
|
||||
t.Run("stdin success", func(t *testing.T) {
|
||||
stdin := bytes.NewReader([]byte("file-content-here"))
|
||||
fd, err := BuildFormdata(fio, "file", "", true, stdin, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fd == nil {
|
||||
t.Fatal("expected non-nil Formdata")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stdin nil reader", func(t *testing.T) {
|
||||
_, err := BuildFormdata(fio, "file", "", true, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil stdin")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stdin is not available") {
|
||||
t.Errorf("error = %q, want containing %q", err.Error(), "stdin is not available")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stdin empty", func(t *testing.T) {
|
||||
stdin := bytes.NewReader([]byte{})
|
||||
_, err := BuildFormdata(fio, "file", "", true, stdin, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty stdin")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stdin is empty") {
|
||||
t.Errorf("error = %q, want containing %q", err.Error(), "stdin is empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file open success", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "test.txt"), []byte("hello"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
fd, err := BuildFormdata(fio, "photo", "test.txt", false, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fd == nil {
|
||||
t.Fatal("expected non-nil Formdata")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file not found", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
|
||||
_, err := BuildFormdata(fio, "file", "nonexistent.txt", false, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot open file:") {
|
||||
t.Errorf("error = %q, want containing %q", err.Error(), "cannot open file:")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dataJSON fields added", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "upload.bin"), []byte("data"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
dataJSON := map[string]any{
|
||||
"file_name": "report.pdf",
|
||||
"parent_type": "doc_image",
|
||||
"size": 1024,
|
||||
}
|
||||
|
||||
fd, err := BuildFormdata(fio, "file", "upload.bin", false, nil, dataJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fd == nil {
|
||||
t.Fatal("expected non-nil Formdata")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dataJSON nil is fine", func(t *testing.T) {
|
||||
stdin := bytes.NewReader([]byte("content"))
|
||||
fd, err := BuildFormdata(fio, "file", "", true, stdin, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fd == nil {
|
||||
t.Fatal("expected non-nil Formdata")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dataJSON non-map is ignored", func(t *testing.T) {
|
||||
stdin := bytes.NewReader([]byte("content"))
|
||||
fd, err := BuildFormdata(fio, "file", "", true, stdin, "not-a-map")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fd == nil {
|
||||
t.Fatal("expected non-nil Formdata")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -36,10 +36,10 @@ func wrapError(op string, err error) error {
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("keychain %s failed: %v", op, err)
|
||||
hint := "Check if the OS keychain/credential manager is locked or accessible. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain."
|
||||
hint := "Check if the OS keychain/credential manager is locked or accessible. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain, you can try running this outside the sandbox."
|
||||
|
||||
if errors.Is(err, errNotInitialized) {
|
||||
hint = "The keychain master key may have been cleaned up or deleted. Please reconfigure the CLI by running `lark-cli config init`."
|
||||
hint = "The keychain master key may have been cleaned up or deleted. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain, you can try running this outside the sandbox. Otherwise, please reconfigure the CLI by running lark-cli config init."
|
||||
}
|
||||
|
||||
func() {
|
||||
|
||||
@@ -564,3 +564,54 @@ func TestCollectScopesForProjects_NonexistentProject(t *testing.T) {
|
||||
t.Errorf("expected empty scopes for nonexistent project, got %d", len(scopes))
|
||||
}
|
||||
}
|
||||
|
||||
// --- auth_domain functions ---
|
||||
|
||||
func TestGetAuthDomain_Configured(t *testing.T) {
|
||||
// whiteboard has auth_domain: "docs" in service_descriptions.json
|
||||
if got := GetAuthDomain("whiteboard"); got != "docs" {
|
||||
t.Errorf("GetAuthDomain(whiteboard) = %q, want %q", got, "docs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthDomain_NotConfigured(t *testing.T) {
|
||||
if got := GetAuthDomain("calendar"); got != "" {
|
||||
t.Errorf("GetAuthDomain(calendar) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthDomain_Unknown(t *testing.T) {
|
||||
if got := GetAuthDomain("nonexistent_xyz"); got != "" {
|
||||
t.Errorf("GetAuthDomain(nonexistent_xyz) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAuthDomain(t *testing.T) {
|
||||
if !HasAuthDomain("whiteboard") {
|
||||
t.Error("HasAuthDomain(whiteboard) = false, want true")
|
||||
}
|
||||
if HasAuthDomain("calendar") {
|
||||
t.Error("HasAuthDomain(calendar) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthChildren(t *testing.T) {
|
||||
children := GetAuthChildren("docs")
|
||||
found := false
|
||||
for _, c := range children {
|
||||
if c == "whiteboard" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GetAuthChildren(docs) = %v, want to contain 'whiteboard'", children)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthChildren_NoChildren(t *testing.T) {
|
||||
children := GetAuthChildren("calendar")
|
||||
if len(children) != 0 {
|
||||
t.Errorf("GetAuthChildren(calendar) = %v, want empty", children)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
"im:message:send_as_bot": 1,
|
||||
"calendar:calendar:read": 70,
|
||||
"calendar:calendar:readonly": 1,
|
||||
"sheets:spreadsheet:write_only": 45,
|
||||
"docs:document.comment:delete": 60,
|
||||
"sheets:spreadsheet:write_only": 60,
|
||||
"drive:drive:readonly": 1,
|
||||
"docs:doc:readonly": 1,
|
||||
"sheets:spreadsheet:readonly": 1,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,9 @@ type serviceDescLocale struct {
|
||||
|
||||
// serviceDescEntry holds bilingual descriptions for a service domain.
|
||||
type serviceDescEntry struct {
|
||||
En serviceDescLocale `json:"en"`
|
||||
Zh serviceDescLocale `json:"zh"`
|
||||
En serviceDescLocale `json:"en"`
|
||||
Zh serviceDescLocale `json:"zh"`
|
||||
AuthDomain string `json:"auth_domain,omitempty"`
|
||||
}
|
||||
|
||||
var serviceDescMap map[string]serviceDescEntry
|
||||
@@ -76,3 +77,31 @@ func GetServiceDetailDescription(name, lang string) string {
|
||||
}
|
||||
return loc.Description
|
||||
}
|
||||
|
||||
// GetAuthDomain returns the auth_domain for a service, or "" if not set.
|
||||
// When auth_domain is set, the service's scopes are collected under the
|
||||
// parent domain during auth login.
|
||||
func GetAuthDomain(service string) string {
|
||||
m := loadServiceDescriptions()
|
||||
if entry, ok := m[service]; ok {
|
||||
return entry.AuthDomain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HasAuthDomain reports whether the service has an auth_domain configured.
|
||||
func HasAuthDomain(service string) bool {
|
||||
return GetAuthDomain(service) != ""
|
||||
}
|
||||
|
||||
// GetAuthChildren returns all service names whose auth_domain equals parent.
|
||||
func GetAuthChildren(parent string) []string {
|
||||
m := loadServiceDescriptions()
|
||||
var children []string
|
||||
for name, entry := range m {
|
||||
if entry.AuthDomain == parent {
|
||||
children = append(children, name)
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
},
|
||||
"whiteboard": {
|
||||
"en": { "title": "Whiteboard", "description": "Create and edit boards" },
|
||||
"zh": { "title": "画板", "description": "画板创建、编辑" }
|
||||
"zh": { "title": "画板", "description": "画板创建、编辑" },
|
||||
"auth_domain": "docs"
|
||||
},
|
||||
"wiki": {
|
||||
"en": { "title": "Wiki", "description": "Wiki space and node management" },
|
||||
|
||||
231
internal/selfupdate/updater.go
Normal file
231
internal/selfupdate/updater.go
Normal file
@@ -0,0 +1,231 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package selfupdate handles installation detection, npm-based updates,
|
||||
// skills updates, and platform-specific binary replacement for the CLI
|
||||
// self-update flow.
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// InstallMethod describes how the CLI was installed.
|
||||
type InstallMethod int
|
||||
|
||||
const (
|
||||
InstallNpm InstallMethod = iota
|
||||
InstallManual
|
||||
)
|
||||
|
||||
const (
|
||||
NpmPackage = "@larksuite/cli"
|
||||
)
|
||||
|
||||
const (
|
||||
npmInstallTimeout = 10 * time.Minute
|
||||
skillsUpdateTimeout = 2 * time.Minute
|
||||
verifyTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
type DetectResult struct {
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
}
|
||||
|
||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||
func (d DetectResult) CanAutoUpdate() bool {
|
||||
return d.Method == InstallNpm && d.NpmAvailable
|
||||
}
|
||||
|
||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||
func (d DetectResult) ManualReason() string {
|
||||
if d.Method == InstallNpm && !d.NpmAvailable {
|
||||
return "installed via npm, but npm is not available in PATH"
|
||||
}
|
||||
return "not installed via npm"
|
||||
}
|
||||
|
||||
// NpmResult holds the result of an npm install or skills update execution.
|
||||
type NpmResult struct {
|
||||
Stdout bytes.Buffer
|
||||
Stderr bytes.Buffer
|
||||
Err error
|
||||
}
|
||||
|
||||
// CombinedOutput returns stdout + stderr concatenated.
|
||||
func (r *NpmResult) CombinedOutput() string {
|
||||
return r.Stdout.String() + r.Stderr.String()
|
||||
}
|
||||
|
||||
// Updater manages self-update operations.
|
||||
// Platform-specific methods (PrepareSelfReplace, CleanupStaleFiles)
|
||||
// are in updater_unix.go and updater_windows.go.
|
||||
//
|
||||
// Override DetectOverride / NpmInstallOverride / SkillsUpdateOverride / VerifyOverride
|
||||
// / RestoreAvailableOverride for testing.
|
||||
type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
SkillsUpdateOverride func() *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
RestoreAvailableOverride func() bool
|
||||
|
||||
// backupCreated is set to true by PrepareSelfReplace (Windows) when the
|
||||
// running binary is successfully renamed to .old. Used by
|
||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||
backupCreated bool
|
||||
}
|
||||
|
||||
// New creates an Updater with default (real) behavior.
|
||||
func New() *Updater { return &Updater{} }
|
||||
|
||||
// DetectInstallMethod determines how the CLI was installed and whether
|
||||
// npm is available for auto-update.
|
||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
if u.DetectOverride != nil {
|
||||
return u.DetectOverride()
|
||||
}
|
||||
exe, err := vfs.Executable()
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual}
|
||||
}
|
||||
resolved, err := vfs.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
||||
}
|
||||
|
||||
method := InstallManual
|
||||
if strings.Contains(resolved, "node_modules") {
|
||||
method = InstallNpm
|
||||
}
|
||||
|
||||
npmAvailable := false
|
||||
if method == InstallNpm {
|
||||
if _, err := exec.LookPath("npm"); err == nil {
|
||||
npmAvailable = true
|
||||
}
|
||||
}
|
||||
|
||||
return DetectResult{
|
||||
Method: method,
|
||||
ResolvedPath: resolved,
|
||||
NpmAvailable: npmAvailable,
|
||||
}
|
||||
}
|
||||
|
||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||
func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
if u.NpmInstallOverride != nil {
|
||||
return u.NpmInstallOverride(version)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
npmPath, err := exec.LookPath("npm")
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("npm not found in PATH: %w", err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npmPath, "install", "-g", NpmPackage+"@"+version)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
r.Err = fmt.Errorf("npm install timed out after %s", npmInstallTimeout)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// RunSkillsUpdate executes npx -y skills add larksuite/cli -g -y.
|
||||
func (u *Updater) RunSkillsUpdate() *NpmResult {
|
||||
if u.SkillsUpdateOverride != nil {
|
||||
return u.SkillsUpdateOverride()
|
||||
}
|
||||
r := &NpmResult{}
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", "larksuite/cli", "-g", "-y")
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
r.Err = fmt.Errorf("skills update timed out after %s", skillsUpdateTimeout)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// VerifyBinary checks that the installed binary reports the expected version
|
||||
// by running "lark-cli --version" and comparing the version token exactly.
|
||||
// Output format is "lark-cli version X.Y.Z"; the last field is extracted and
|
||||
// compared against expectedVersion (both stripped of any "v" prefix).
|
||||
func (u *Updater) VerifyBinary(expectedVersion string) error {
|
||||
if u.VerifyOverride != nil {
|
||||
return u.VerifyOverride(expectedVersion)
|
||||
}
|
||||
// Prefer the current executable path (what the user actually launched).
|
||||
// Use Executable() directly without EvalSymlinks — after npm install the
|
||||
// symlink target may have changed, but the path itself is still valid for
|
||||
// execution. Fall back to LookPath only if Executable() fails entirely.
|
||||
exe, err := vfs.Executable()
|
||||
if err != nil {
|
||||
exe, err = exec.LookPath("lark-cli")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot locate binary: %w", err)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), verifyTimeout)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, exe, "--version").Output()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("binary verification timed out after %s", verifyTimeout)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("binary not executable: %w", err)
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSpace(string(out)))
|
||||
if len(fields) == 0 {
|
||||
return fmt.Errorf("empty version output")
|
||||
}
|
||||
actual := strings.TrimPrefix(fields[len(fields)-1], "v")
|
||||
expected := strings.TrimPrefix(expectedVersion, "v")
|
||||
if actual != expected {
|
||||
return fmt.Errorf("expected version %s, got %q", expectedVersion, actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Truncate returns the last maxLen runes of s.
|
||||
func Truncate(s string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return string(r[len(r)-maxLen:])
|
||||
}
|
||||
|
||||
// resolveExe returns the resolved path of the current running binary.
|
||||
func (u *Updater) resolveExe() (string, error) {
|
||||
exe, err := vfs.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return vfs.EvalSymlinks(exe)
|
||||
}
|
||||
89
internal/selfupdate/updater_test.go
Normal file
89
internal/selfupdate/updater_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type executableTestFS struct {
|
||||
vfs.OsFs
|
||||
exe string
|
||||
}
|
||||
|
||||
func (f executableTestFS) Executable() (string, error) { return f.exe, nil }
|
||||
|
||||
func TestResolveExe(t *testing.T) {
|
||||
u := New()
|
||||
p, err := u.resolveExe()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveExe() error: %v", err)
|
||||
}
|
||||
if !filepath.IsAbs(p) {
|
||||
t.Errorf("expected absolute path, got: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSelfReplace_ReturnsNoError(t *testing.T) {
|
||||
u := New()
|
||||
restore, err := u.PrepareSelfReplace()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
restore()
|
||||
}
|
||||
|
||||
func TestCleanupStaleFiles_NoPanic(t *testing.T) {
|
||||
u := New()
|
||||
u.CleanupStaleFiles()
|
||||
}
|
||||
|
||||
func TestVerifyBinaryChecksVersion(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell script")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "lark-cli")
|
||||
// Script prints version string matching real CLI format when --version is passed.
|
||||
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.0.0\"; exit 0; fi\nexit 12\n"
|
||||
if err := os.WriteFile(exe, []byte(script), 0755); err != nil {
|
||||
t.Fatalf("write test binary: %v", err)
|
||||
}
|
||||
|
||||
// Mock vfs.Executable to return our test script, matching VerifyBinary's
|
||||
// primary lookup path. Also prepend to PATH for the LookPath fallback.
|
||||
origFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = executableTestFS{OsFs: vfs.OsFs{}, exe: exe}
|
||||
t.Cleanup(func() { vfs.DefaultFS = origFS })
|
||||
|
||||
origPath := os.Getenv("PATH")
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath)
|
||||
|
||||
// Matching version → success.
|
||||
if err := New().VerifyBinary("2.0.0"); err != nil {
|
||||
t.Fatalf("VerifyBinary(matching) error = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Mismatched version → error.
|
||||
if err := New().VerifyBinary("3.0.0"); err == nil {
|
||||
t.Fatal("VerifyBinary(mismatched) expected error, got nil")
|
||||
}
|
||||
|
||||
// Substring of actual version must not match (e.g. "0.0" is in "2.0.0").
|
||||
if err := New().VerifyBinary("0.0"); err == nil {
|
||||
t.Fatal("VerifyBinary(substring) expected error, got nil")
|
||||
}
|
||||
|
||||
// Version that is a prefix of actual must not match (e.g. "2.0.0" in "12.0.0").
|
||||
// Binary reports "2.0.0", asking for "12.0.0" must fail.
|
||||
if err := New().VerifyBinary("12.0.0"); err == nil {
|
||||
t.Fatal("VerifyBinary(prefix-mismatch) expected error, got nil")
|
||||
}
|
||||
}
|
||||
24
internal/selfupdate/updater_unix.go
Normal file
24
internal/selfupdate/updater_unix.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package selfupdate
|
||||
|
||||
// PrepareSelfReplace is a no-op on Unix.
|
||||
// Unix allows overwriting a running executable via inode semantics.
|
||||
func (u *Updater) PrepareSelfReplace() (restore func(), err error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
// CleanupStaleFiles is a no-op on Unix (no .old files are created).
|
||||
func (u *Updater) CleanupStaleFiles() {}
|
||||
|
||||
// CanRestorePreviousVersion reports whether PrepareSelfReplace created a
|
||||
// restorable backup for the current update attempt.
|
||||
func (u *Updater) CanRestorePreviousVersion() bool {
|
||||
if u.RestoreAvailableOverride != nil {
|
||||
return u.RestoreAvailableOverride()
|
||||
}
|
||||
return u.backupCreated
|
||||
}
|
||||
87
internal/selfupdate/updater_windows.go
Normal file
87
internal/selfupdate/updater_windows.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// PrepareSelfReplace renames the running .exe to .old so that npm's
|
||||
// postinstall script can write the new binary without hitting EBUSY.
|
||||
// Returns a restore function that undoes the rename on failure.
|
||||
func (u *Updater) PrepareSelfReplace() (restore func(), err error) {
|
||||
noop := func() {}
|
||||
|
||||
exe, err := u.resolveExe()
|
||||
if err != nil {
|
||||
return noop, nil // best-effort; don't block update
|
||||
}
|
||||
|
||||
oldPath := exe + ".old"
|
||||
|
||||
// Clean up stale .old from a previous upgrade.
|
||||
vfs.Remove(oldPath)
|
||||
|
||||
// Rename running.exe → running.exe.old (Windows allows rename of locked files).
|
||||
if err := vfs.Rename(exe, oldPath); err != nil {
|
||||
return noop, fmt.Errorf("cannot rename binary for update: %w", err)
|
||||
}
|
||||
u.backupCreated = true
|
||||
|
||||
// Restore: move .old back to the original path.
|
||||
// Guard with Stat: run.js may have already recovered .old on its own
|
||||
// during VerifyBinary; if .old is gone, skip to avoid deleting the
|
||||
// only working binary.
|
||||
// On any failure, clear backupCreated so CanRestorePreviousVersion
|
||||
// reports the real outcome instead of claiming success.
|
||||
restore = func() {
|
||||
if _, err := vfs.Stat(oldPath); err != nil {
|
||||
u.backupCreated = false
|
||||
return
|
||||
}
|
||||
vfs.Remove(exe)
|
||||
if err := vfs.Rename(oldPath, exe); err != nil {
|
||||
u.backupCreated = false
|
||||
}
|
||||
}
|
||||
|
||||
return restore, nil
|
||||
}
|
||||
|
||||
// CleanupStaleFiles removes leftover .old files from previous upgrades.
|
||||
// If the original binary is missing but .old exists (crash mid-update),
|
||||
// it restores the .old to recover the installation.
|
||||
func (u *Updater) CleanupStaleFiles() {
|
||||
exe, err := u.resolveExe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
oldPath := exe + ".old"
|
||||
|
||||
if _, err := vfs.Stat(oldPath); err != nil {
|
||||
return // no .old file
|
||||
}
|
||||
|
||||
if _, err := vfs.Stat(exe); err != nil {
|
||||
// Original missing, .old exists — restore to recover.
|
||||
vfs.Rename(oldPath, exe)
|
||||
return
|
||||
}
|
||||
|
||||
// Both exist — .old is stale, clean up.
|
||||
vfs.Remove(oldPath)
|
||||
}
|
||||
|
||||
// CanRestorePreviousVersion reports whether PrepareSelfReplace created a
|
||||
// restorable backup for the current update attempt.
|
||||
func (u *Updater) CanRestorePreviousVersion() bool {
|
||||
if u.RestoreAvailableOverride != nil {
|
||||
return u.RestoreAvailableOverride()
|
||||
}
|
||||
return u.backupCreated
|
||||
}
|
||||
@@ -218,8 +218,8 @@ func fetchLatestVersion() (string, error) {
|
||||
// is considered newer — an unparseable local version is assumed outdated.
|
||||
// When a cannot be parsed, returns false (can't confirm it's newer).
|
||||
func IsNewer(a, b string) bool {
|
||||
ap := ParseVersion(a)
|
||||
bp := ParseVersion(b)
|
||||
ap := parseVersionDetail(a)
|
||||
bp := parseVersionDetail(b)
|
||||
if ap == nil {
|
||||
return false // can't confirm remote is newer
|
||||
}
|
||||
@@ -227,28 +227,59 @@ func IsNewer(a, b string) bool {
|
||||
return true // local version unparseable → assume outdated
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if ap[i] > bp[i] {
|
||||
if ap.core[i] > bp.core[i] {
|
||||
return true
|
||||
}
|
||||
if ap[i] < bp[i] {
|
||||
if ap.core[i] < bp.core[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
return comparePrerelease(ap.prerelease, bp.prerelease) > 0
|
||||
}
|
||||
|
||||
// ParseVersion parses "X.Y.Z" (with optional "v" prefix and pre-release suffix)
|
||||
// into [major, minor, patch]. Returns nil on invalid input.
|
||||
func ParseVersion(v string) []int {
|
||||
parsed := parseVersionDetail(v)
|
||||
if parsed == nil {
|
||||
return nil
|
||||
}
|
||||
return []int{parsed.core[0], parsed.core[1], parsed.core[2]}
|
||||
}
|
||||
|
||||
type parsedVersion struct {
|
||||
core [3]int
|
||||
prerelease string
|
||||
}
|
||||
|
||||
// validPrerelease matches semver pre-release identifiers (dot-separated).
|
||||
// Each identifier is either: "0", a non-zero-leading numeric, or alphanumeric with at least one letter/hyphen.
|
||||
// Rejects empty identifiers ("1.0.0-"), leading-zero numerics ("1.0.0-01"), etc.
|
||||
var validPrerelease = regexp.MustCompile(
|
||||
`^(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)` +
|
||||
`(?:\.(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*$`)
|
||||
|
||||
func parseVersionDetail(v string) *parsedVersion {
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
if idx := strings.Index(v, "+"); idx >= 0 {
|
||||
v = v[:idx]
|
||||
}
|
||||
prerelease := ""
|
||||
if idx := strings.Index(v, "-"); idx >= 0 {
|
||||
prerelease = v[idx+1:]
|
||||
v = v[:idx]
|
||||
if prerelease == "" || !validPrerelease.MatchString(prerelease) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
parts := strings.SplitN(v, ".", 3)
|
||||
if len(parts) != 3 {
|
||||
return nil
|
||||
}
|
||||
nums := make([]int, 3)
|
||||
var nums [3]int
|
||||
for i, p := range parts {
|
||||
if idx := strings.IndexAny(p, "-+"); idx >= 0 {
|
||||
p = p[:idx]
|
||||
if len(p) > 1 && p[0] == '0' {
|
||||
return nil // leading zero in core part (e.g. "01.0.0")
|
||||
}
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
@@ -256,5 +287,56 @@ func ParseVersion(v string) []int {
|
||||
}
|
||||
nums[i] = n
|
||||
}
|
||||
return nums
|
||||
return &parsedVersion{core: nums, prerelease: prerelease}
|
||||
}
|
||||
|
||||
func comparePrerelease(a, b string) int {
|
||||
if a == "" && b == "" {
|
||||
return 0
|
||||
}
|
||||
if a == "" {
|
||||
return 1
|
||||
}
|
||||
if b == "" {
|
||||
return -1
|
||||
}
|
||||
ap := strings.Split(a, ".")
|
||||
bp := strings.Split(b, ".")
|
||||
for i := 0; i < len(ap) && i < len(bp); i++ {
|
||||
cmp := comparePrereleaseIdentifier(ap[i], bp[i])
|
||||
if cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(ap) > len(bp):
|
||||
return 1
|
||||
case len(ap) < len(bp):
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func comparePrereleaseIdentifier(a, b string) int {
|
||||
an, aErr := strconv.Atoi(a)
|
||||
bn, bErr := strconv.Atoi(b)
|
||||
aNumeric := aErr == nil
|
||||
bNumeric := bErr == nil
|
||||
switch {
|
||||
case aNumeric && bNumeric:
|
||||
if an > bn {
|
||||
return 1
|
||||
}
|
||||
if an < bn {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
case aNumeric:
|
||||
return -1
|
||||
case bNumeric:
|
||||
return 1
|
||||
default:
|
||||
return strings.Compare(a, b)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ func TestIsNewer(t *testing.T) {
|
||||
{"1.0.0", "9b933f1", true}, // bare commit hash → assume outdated
|
||||
{"", "1.0.0", false}, // empty remote → false
|
||||
{"1.1.0", "v1.0.0-12-g9b933f1-dirty", true}, // git describe: 1.1.0 > 1.0.0
|
||||
{"1.0.0", "1.0.0-rc.1", true}, // stable release > prerelease
|
||||
{"1.0.0-rc.2", "1.0.0-rc.1", true}, // prerelease identifiers are ordered
|
||||
{"1.0.0-rc.1", "1.0.0", false}, // prerelease < stable release
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := IsNewer(tt.a, tt.b)
|
||||
@@ -74,6 +77,16 @@ func TestParseVersion(t *testing.T) {
|
||||
{"v1.2.3", []int{1, 2, 3}},
|
||||
{"0.0.1", []int{0, 0, 1}},
|
||||
{"1.0.0-beta.1", []int{1, 0, 0}},
|
||||
{"1.0.0-rc.1", []int{1, 0, 0}},
|
||||
{"1.0.0-0", []int{1, 0, 0}},
|
||||
{"1.0.0+build.123", []int{1, 0, 0}},
|
||||
{"1.0.0-beta.1+build", []int{1, 0, 0}},
|
||||
{"1.0.0-", nil}, // empty pre-release
|
||||
{"1.0.0-01", nil}, // leading zero in numeric pre-release
|
||||
{"1.0.0-beta..1", nil}, // empty identifier between dots
|
||||
{"01.0.0", nil}, // leading zero in major
|
||||
{"1.00.0", nil}, // leading zero in minor
|
||||
{"1.0.00", nil}, // leading zero in patch
|
||||
{"DEV", nil},
|
||||
{"", nil},
|
||||
{"1.2", nil},
|
||||
|
||||
@@ -31,3 +31,5 @@ func MkdirAll(path string, perm fs.FileMode) error { return DefaultFS.MkdirA
|
||||
func ReadDir(name string) ([]os.DirEntry, error) { return DefaultFS.ReadDir(name) }
|
||||
func Remove(name string) error { return DefaultFS.Remove(name) }
|
||||
func Rename(oldpath, newpath string) error { return DefaultFS.Rename(oldpath, newpath) }
|
||||
func EvalSymlinks(path string) (string, error) { return DefaultFS.EvalSymlinks(path) }
|
||||
func Executable() (string, error) { return DefaultFS.Executable() }
|
||||
|
||||
@@ -29,4 +29,8 @@ type FS interface {
|
||||
ReadDir(name string) ([]os.DirEntry, error)
|
||||
Remove(name string) error
|
||||
Rename(oldpath, newpath string) error
|
||||
|
||||
// Path resolution
|
||||
EvalSymlinks(path string) (string, error)
|
||||
Executable() (string, error)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package vfs
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// OsFs delegates every method to the os standard library.
|
||||
@@ -33,3 +34,7 @@ func (OsFs) MkdirAll(path string, perm fs.FileMode) error { return os.MkdirAll(p
|
||||
func (OsFs) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
|
||||
func (OsFs) Remove(name string) error { return os.Remove(name) }
|
||||
func (OsFs) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) }
|
||||
|
||||
// Path resolution
|
||||
func (OsFs) EvalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) }
|
||||
func (OsFs) Executable() (string, error) { return os.Executable() }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.8",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -9,6 +9,38 @@ const path = require("path");
|
||||
const ext = process.platform === "win32" ? ".exe" : "";
|
||||
const bin = path.join(__dirname, "..", "bin", "lark-cli" + ext);
|
||||
|
||||
// On Windows, a crashed self-update may have left the binary renamed to .old.
|
||||
// Recover it before proceeding so the CLI remains functional.
|
||||
const oldBin = bin + ".old";
|
||||
function restoreOldBinary() {
|
||||
try {
|
||||
if (fs.existsSync(bin)) {
|
||||
fs.rmSync(bin, { force: true });
|
||||
}
|
||||
fs.renameSync(oldBin, bin);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === "win32" && fs.existsSync(oldBin)) {
|
||||
if (!fs.existsSync(bin)) {
|
||||
restoreOldBinary();
|
||||
} else {
|
||||
try {
|
||||
execFileSync(bin, ["--version"], { stdio: "ignore", timeout: 10000 });
|
||||
try {
|
||||
fs.rmSync(oldBin, { force: true });
|
||||
} catch (_) {
|
||||
// Best-effort cleanup; keep running the healthy binary.
|
||||
}
|
||||
} catch (_) {
|
||||
restoreOldBinary();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(bin)) {
|
||||
console.error(
|
||||
`Error: lark-cli binary not found at ${bin}\n\n` +
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
// ── Dashboard CRUD ──────────────────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardExecuteList tests the +dashboard-list command.
|
||||
func TestBaseDashboardExecuteList(t *testing.T) {
|
||||
t.Run("single page", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -41,6 +42,7 @@ func TestBaseDashboardExecuteList(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
// TestBaseDashboardExecuteGet tests the +dashboard-get command.
|
||||
func TestBaseDashboardExecuteGet(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -67,6 +69,7 @@ func TestBaseDashboardExecuteGet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardExecuteCreate tests the +dashboard-create command.
|
||||
func TestBaseDashboardExecuteCreate(t *testing.T) {
|
||||
t.Run("name only", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -114,6 +117,7 @@ func TestBaseDashboardExecuteCreate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardExecuteUpdate tests the +dashboard-update command.
|
||||
func TestBaseDashboardExecuteUpdate(t *testing.T) {
|
||||
t.Run("update name", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -161,6 +165,7 @@ func TestBaseDashboardExecuteUpdate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardExecuteDelete tests the +dashboard-delete command.
|
||||
func TestBaseDashboardExecuteDelete(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -179,6 +184,7 @@ func TestBaseDashboardExecuteDelete(t *testing.T) {
|
||||
|
||||
// ── Dashboard Block CRUD ────────────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardBlockExecuteList tests the +dashboard-block-list command.
|
||||
func TestBaseDashboardBlockExecuteList(t *testing.T) {
|
||||
t.Run("single page", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -208,6 +214,7 @@ func TestBaseDashboardBlockExecuteList(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockExecuteGet tests the +dashboard-block-get command.
|
||||
func TestBaseDashboardBlockExecuteGet(t *testing.T) {
|
||||
t.Run("basic", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -261,6 +268,7 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockExecuteCreate tests the +dashboard-block-create command.
|
||||
func TestBaseDashboardBlockExecuteCreate(t *testing.T) {
|
||||
t.Run("with data-config", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -354,6 +362,7 @@ func TestBaseDashboardBlockExecuteCreate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockExecuteUpdate tests the +dashboard-block-update command.
|
||||
func TestBaseDashboardBlockExecuteUpdate(t *testing.T) {
|
||||
t.Run("update name and data-config", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -420,6 +429,7 @@ func TestBaseDashboardBlockExecuteUpdate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockExecuteDelete tests the +dashboard-block-delete command.
|
||||
func TestBaseDashboardBlockExecuteDelete(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -438,6 +448,7 @@ func TestBaseDashboardBlockExecuteDelete(t *testing.T) {
|
||||
|
||||
// ── Dry Run: Dashboard & Blocks ──────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardDryRun_List tests the +dashboard-list --dry-run flag.
|
||||
func TestBaseDashboardDryRun_List(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseDashboardList, []string{"+dashboard-list", "--base-token", "app_x", "--page-size", "50", "--dry-run", "--format", "pretty"}, factory, stdout); err != nil {
|
||||
@@ -449,6 +460,7 @@ func TestBaseDashboardDryRun_List(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardDryRun_Get tests the +dashboard-get --dry-run flag.
|
||||
func TestBaseDashboardDryRun_Get(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseDashboardGet, []string{"+dashboard-get", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--dry-run", "--format", "pretty"}, factory, stdout); err != nil {
|
||||
@@ -460,6 +472,7 @@ func TestBaseDashboardDryRun_Get(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardDryRun_Create tests the +dashboard-create --dry-run flag.
|
||||
func TestBaseDashboardDryRun_Create(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-create", "--base-token", "app_x", "--name", "新报表", "--theme-style", "default", "--dry-run", "--format", "pretty"}
|
||||
@@ -472,6 +485,7 @@ func TestBaseDashboardDryRun_Create(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardDryRun_Update tests the +dashboard-update --dry-run flag.
|
||||
func TestBaseDashboardDryRun_Update(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--name", "更新名", "--dry-run", "--format", "pretty"}
|
||||
@@ -484,6 +498,7 @@ func TestBaseDashboardDryRun_Update(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardDryRun_Delete tests the +dashboard-delete --dry-run flag.
|
||||
func TestBaseDashboardDryRun_Delete(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-delete", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--dry-run", "--format", "pretty"}
|
||||
@@ -496,6 +511,7 @@ func TestBaseDashboardDryRun_Delete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockDryRun_List tests the +dashboard-block-list --dry-run flag.
|
||||
func TestBaseDashboardBlockDryRun_List(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-list", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--page-size", "10", "--dry-run", "--format", "pretty"}
|
||||
@@ -508,6 +524,7 @@ func TestBaseDashboardBlockDryRun_List(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockDryRun_Get tests the +dashboard-block-get --dry-run flag.
|
||||
func TestBaseDashboardBlockDryRun_Get(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--user-id-type", "union_id", "--dry-run", "--format", "pretty"}
|
||||
@@ -520,6 +537,7 @@ func TestBaseDashboardBlockDryRun_Get(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockDryRun_Create tests the +dashboard-block-create --dry-run flag.
|
||||
func TestBaseDashboardBlockDryRun_Create(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--name", "订单趋势", "--type", "column", "--data-config", `{"table_name":"订单表","count_all":true}`, "--user-id-type", "open_id", "--dry-run", "--format", "pretty"}
|
||||
@@ -532,6 +550,7 @@ func TestBaseDashboardBlockDryRun_Create(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockDryRun_Update tests the +dashboard-block-update --dry-run flag.
|
||||
func TestBaseDashboardBlockDryRun_Update(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--name", "订单趋势v2", "--data-config", `{"table_name":"订单表2","count_all":true}`, "--dry-run", "--format", "pretty"}
|
||||
@@ -544,6 +563,7 @@ func TestBaseDashboardBlockDryRun_Update(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockDryRun_Delete tests the +dashboard-block-delete --dry-run flag.
|
||||
func TestBaseDashboardBlockDryRun_Delete(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-delete", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--dry-run", "--format", "pretty"}
|
||||
@@ -558,6 +578,7 @@ func TestBaseDashboardBlockDryRun_Delete(t *testing.T) {
|
||||
|
||||
// ── Validator: data_config ───────────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardBlockCreate_ValidateFails tests that data_config validation catches missing table_name.
|
||||
func TestBaseDashboardBlockCreate_ValidateFails(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
// 缺 table_name 且 series 与 count_all 同时存在
|
||||
@@ -574,6 +595,7 @@ func TestBaseDashboardBlockCreate_ValidateFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockCreate_NoValidateFlagAllocs tests that --no-validate flag skips client-side validation.
|
||||
func TestBaseDashboardBlockCreate_NoValidateFlagAllocs(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks",
|
||||
@@ -591,6 +613,7 @@ func TestBaseDashboardBlockCreate_NoValidateFlagAllocs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockCreate_InvalidRollup tests that invalid rollup values are rejected during validation.
|
||||
func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
// 合法 JSON,但 rollup=COUNTA(不支持)
|
||||
@@ -606,3 +629,186 @@ func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text Block Tests ────────────────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardBlockExecuteCreate_TextType tests creating text blocks with markdown content.
|
||||
func TestBaseDashboardBlockExecuteCreate_TextType(t *testing.T) {
|
||||
t.Run("valid text block", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"block_id": "blk_text",
|
||||
"name": "说明文字",
|
||||
"type": "text",
|
||||
"data_config": map[string]interface{}{
|
||||
"text": "# 标题\n**加粗**",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001",
|
||||
"--name", "说明文字", "--type", "text",
|
||||
"--data-config", `{"text":"# 标题\n**加粗**"}`,
|
||||
}
|
||||
if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"blk_text"`) || !strings.Contains(got, `"created": true`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("text block missing text field", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001",
|
||||
"--name", "Bad", "--type", "text",
|
||||
"--data-config", `{}`,
|
||||
}
|
||||
err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for missing text field")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "text") || !strings.Contains(got, "data_config 校验失败") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockExecuteUpdate_TextType tests updating text block content and name.
|
||||
func TestBaseDashboardBlockExecuteUpdate_TextType(t *testing.T) {
|
||||
t.Run("update text content", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_text",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"block_id": "blk_text",
|
||||
"name": "更新后的标题",
|
||||
"type": "text",
|
||||
"data_config": map[string]interface{}{
|
||||
"text": "# 新内容",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_text",
|
||||
"--name", "更新后的标题",
|
||||
"--data-config", `{"text":"# 新内容"}`,
|
||||
}
|
||||
if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"updated": true`) || !strings.Contains(got, "新内容") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("update without type skips strict validation", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
// update 不传 type,不做强类型校验,直接透传给后端
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_text",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"block_id": "blk_text",
|
||||
"type": "text",
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_text",
|
||||
"--data-config", `{"content":"xxx"}`,
|
||||
}
|
||||
// 不传 type,本地不做强校验,让后端处理
|
||||
err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Dashboard Arrange ────────────────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardExecuteArrange tests the +dashboard-arrange command for auto-arranging dashboard blocks.
|
||||
func TestBaseDashboardExecuteArrange(t *testing.T) {
|
||||
t.Run("arrange dashboard blocks", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/arrange",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"dashboard_id": "dsh_001",
|
||||
"name": "测试仪表盘",
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_id": "cht_xxx",
|
||||
"block_name": "组件1",
|
||||
"block_type": "column",
|
||||
"layout": map[string]interface{}{
|
||||
"x": 0, "y": 0, "w": 500, "h": 400,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "dsh_001"}
|
||||
if err := runShortcut(t, BaseDashboardArrange, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"arranged": true`) || !strings.Contains(got, `"dashboard_id"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("arrange with user-id-type", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "user_id_type=union_id",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"dashboard_id": "dsh_001",
|
||||
"blocks": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--user-id-type", "union_id"}
|
||||
if err := runShortcut(t, BaseDashboardArrange, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"arranged": true`) || !strings.Contains(got, `"dashboard_id"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardDryRun_Arrange tests the +dashboard-arrange --dry-run flag includes empty body.
|
||||
func TestBaseDashboardDryRun_Arrange(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--user-id-type", "union_id", "--dry-run", "--format", "pretty"}
|
||||
if err := runShortcut(t, BaseDashboardArrange, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "POST /open-apis/base/v3/bases/app_x/dashboards/dsh_001/arrange") || !strings.Contains(got, "union_id") || !strings.Contains(got, "{}") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,18 +63,49 @@ func TestDryRunFieldOps(t *testing.T) {
|
||||
func TestDryRunRecordOps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
listRT := newBaseTestRuntime(
|
||||
listRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "view-id": "viw_1"},
|
||||
map[string][]string{"field-id": {"Name", "Age"}},
|
||||
nil,
|
||||
map[string]int{"offset": -3, "limit": 500},
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1")
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
|
||||
|
||||
commaFieldRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"field-id": {"A,B", "C"}},
|
||||
nil,
|
||||
map[string]int{"limit": 1},
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, commaFieldRT), "limit=1", "offset=0", "field_id=A%2CB", "field_id=C")
|
||||
|
||||
searchRT := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"json": `{"view_id":"viw_1","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":-1,"limit":500}`,
|
||||
},
|
||||
nil, nil,
|
||||
)
|
||||
assertDryRunContains(
|
||||
t,
|
||||
dryRunRecordSearch(ctx, searchRT),
|
||||
"POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/search",
|
||||
`"view_id":"viw_1"`,
|
||||
`"keyword":"Created"`,
|
||||
`"search_fields":["Title","fld_owner"]`,
|
||||
`"select_fields":["Title","fld_owner"]`,
|
||||
`"offset":-1`,
|
||||
`"limit":500`,
|
||||
)
|
||||
|
||||
upsertCreateRT := newBaseTestRuntime(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`},
|
||||
nil, nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordUpsert(ctx, upsertCreateRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records")
|
||||
assertDryRunContains(t, dryRunRecordBatchCreate(ctx, upsertCreateRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_create")
|
||||
assertDryRunContains(t, dryRunRecordBatchUpdate(ctx, upsertCreateRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_update")
|
||||
|
||||
rt := newBaseTestRuntime(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "record-id": "rec_1", "json": `{"Name":"B"}`},
|
||||
@@ -211,6 +242,7 @@ func TestDryRunViewOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunViewSetWrapped(setWrappedInvalidRT, "group", "group_config"), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/group")
|
||||
|
||||
assertDryRunContains(t, dryRunViewGetFilter(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/filter")
|
||||
assertDryRunContains(t, dryRunViewGetVisibleFields(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/visible_fields")
|
||||
assertDryRunContains(t, dryRunViewGetGroup(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/group")
|
||||
assertDryRunContains(t, dryRunViewGetSort(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/sort")
|
||||
assertDryRunContains(t, dryRunViewGetTimebar(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/timebar")
|
||||
|
||||
@@ -303,7 +303,7 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFieldList, []string{"+field-list", "--base-token", "app_x", "--table-id", "tbl_x", "--offset", "0", "--limit", "1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"field_name": "Amount"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"name": "Amount"`) || strings.Contains(got, `"items"`) || strings.Contains(got, `"offset"`) || strings.Contains(got, `"limit"`) || strings.Contains(got, `"count"`) || strings.Contains(got, `"field_name": "Amount"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -376,7 +376,7 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
|
||||
if err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x", "--limit", "1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"table_name": "Alpha"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"tables"`) || !strings.Contains(got, `"name": "Alpha"`) || strings.Contains(got, `"items"`) || strings.Contains(got, `"offset"`) || strings.Contains(got, `"limit"`) || strings.Contains(got, `"count"`) || strings.Contains(got, `"table_name": "Alpha"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -427,7 +427,7 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
|
||||
if err := runShortcut(t, BaseTableGet, []string{"+table-get", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"name": "Orders"`) || !strings.Contains(got, `"primary_field": "fld_x"`) || !strings.Contains(got, `"vew_x"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"name": "Orders"`) || !strings.Contains(got, `"primary_field": "fld_x"`) || !strings.Contains(got, `"id": "fld_x"`) || !strings.Contains(got, `"name": "OrderNo"`) || !strings.Contains(got, `"id": "vew_x"`) || !strings.Contains(got, `"name": "Main"`) || strings.Contains(got, `"field_name": "OrderNo"`) || strings.Contains(got, `"view_name": "Main"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -471,6 +471,52 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list with fields and view", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=1&offset=0&view_id=vew_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_fields"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list with comma field", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=A%2CB&field_id=C&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"A,B", "C"},
|
||||
"record_id_list": []interface{}{"rec_json_fields"},
|
||||
"data": []interface{}{[]interface{}{"value-1", "value-2"}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"A,B"`) || !strings.Contains(got, `"rec_json_fields"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list new shape", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -494,6 +540,72 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Title", "Owner"},
|
||||
"field_id_list": []interface{}{"fld_title", "fld_owner"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Created by AI", "Alice"}},
|
||||
"has_more": false,
|
||||
"query_context": map[string]interface{}{
|
||||
"record_scope": "filtered_records",
|
||||
"field_scope": "selected_fields",
|
||||
"search_scope": "fld_title(Title)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(
|
||||
t,
|
||||
BaseRecordSearch,
|
||||
[]string{
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"query_context"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(searchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"view_id":"vew_x"`) ||
|
||||
!strings.Contains(body, `"keyword":"Created"`) ||
|
||||
!strings.Contains(body, `"search_fields":["Title","fld_owner"]`) ||
|
||||
!strings.Contains(body, `"select_fields":["Title","fld_owner"]`) ||
|
||||
!strings.Contains(body, `"offset":0`) ||
|
||||
!strings.Contains(body, `"limit":2`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list legacy fields flag rejected", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -552,6 +664,75 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch create", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_create",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1", "rec_2"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{"Bob"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":["Name"],"rows":[["Alice"],["Bob"]]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch update", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"update": map[string]interface{}{"Status": "Done"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Status":"Done"}}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"update"`) || !strings.Contains(got, `"Done"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch update passthrough", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Name":"Alice","Status":"Done"}}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"patch":{"Name":"Alice","Status":"Done"}`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -739,7 +920,7 @@ func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
if err := runShortcut(t, BaseViewList, []string{"+view-list", "--base-token", "app_x", "--table-id", "tbl_x", "--offset", "0", "--limit", "1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 3`) || !strings.Contains(got, `"view_name": "Main"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 3`) || !strings.Contains(got, `"views"`) || !strings.Contains(got, `"name": "Main"`) || strings.Contains(got, `"items"`) || strings.Contains(got, `"offset"`) || strings.Contains(got, `"limit"`) || strings.Contains(got, `"count"`) || strings.Contains(got, `"view_name": "Main"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -812,6 +993,61 @@ func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get-visible-fields", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1/visible_fields",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": []interface{}{"fld_primary", "fld_status"},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseViewGetVisibleFields, []string{"+view-get-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"visible_fields"`) || !strings.Contains(got, `"fld_primary"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("set-visible-fields-array-invalid", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(
|
||||
t,
|
||||
BaseViewSetVisibleFields,
|
||||
[]string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1", "--json", `["fld_status"]`},
|
||||
factory,
|
||||
stdout,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("set-visible-fields-object", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1/visible_fields",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": []interface{}{"fld_primary", "fld_status"},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
if err := runShortcut(t, BaseViewSetVisibleFields, []string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1", "--json", `{"visible_fields":["fld_status"]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
body := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(body, `"visible_fields":["fld_status"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
if strings.Contains(body, `{"visible_fields":{"visible_fields":`) {
|
||||
t.Fatalf("request body double wrapped: %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseTableExecuteListFallbackShapes(t *testing.T) {
|
||||
|
||||
@@ -18,10 +18,17 @@ import (
|
||||
)
|
||||
|
||||
func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
return newBaseTestRuntimeWithArrays(stringFlags, nil, boolFlags, intFlags)
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for name := range stringFlags {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range stringArrayFlags {
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
@@ -32,6 +39,11 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
|
||||
for name, value := range stringFlags {
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
for name, values := range stringArrayFlags {
|
||||
for _, value := range values {
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if value {
|
||||
_ = cmd.Flags().Set(name, "true")
|
||||
@@ -108,13 +120,19 @@ func TestWrapViewPropertyBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewSetVisibleFieldsNoValidateHook(t *testing.T) {
|
||||
if BaseViewSetVisibleFields.Validate != nil {
|
||||
t.Fatalf("expected no validate hook, got non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutsCatalog(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
want := []string{
|
||||
"+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-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-get", "+record-upsert", "+record-upload-attachment", "+record-delete",
|
||||
"+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-history-list",
|
||||
"+base-get", "+base-copy", "+base-create",
|
||||
"+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable",
|
||||
@@ -122,7 +140,7 @@ func TestShortcutsCatalog(t *testing.T) {
|
||||
"+data-query",
|
||||
"+form-create", "+form-delete", "+form-list", "+form-update", "+form-get",
|
||||
"+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list",
|
||||
"+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete",
|
||||
"+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange",
|
||||
"+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete",
|
||||
}
|
||||
if len(shortcuts) != len(want) {
|
||||
@@ -234,21 +252,19 @@ func TestBaseTableValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseRecordValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if BaseRecordList.Validate != nil {
|
||||
t.Fatalf("record list validate should be nil after removing --fields")
|
||||
t.Fatalf("record list validate should be nil for repeatable --field-id")
|
||||
}
|
||||
if BaseRecordSearch.Validate != nil {
|
||||
t.Fatalf("record search validate should be nil for API passthrough")
|
||||
}
|
||||
if BaseRecordGet.Validate != nil {
|
||||
t.Fatalf("record get validate should be nil after removing --fields")
|
||||
t.Fatalf("record get validate should be nil")
|
||||
}
|
||||
if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"A"}`}, nil, nil)); err != nil {
|
||||
t.Fatalf("upsert validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": "{"}, nil, nil)); err != nil {
|
||||
t.Fatalf("invalid record json should bypass CLI validate, err=%v", err)
|
||||
if BaseRecordUpsert.Validate != nil {
|
||||
t.Fatalf("record upsert validate should be nil for API passthrough")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseViewValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil {
|
||||
|
||||
29
shortcuts/base/dashboard_arrange.go
Normal file
29
shortcuts/base/dashboard_arrange.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseDashboardArrange = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+dashboard-arrange",
|
||||
Description: "Auto-arrange dashboard blocks layout (server-side smart layout)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:dashboard:update"},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
},
|
||||
DryRun: dryRunDashboardArrange,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeDashboardArrange(runtime)
|
||||
},
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package base
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -23,7 +24,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
{Name: "name", Desc: "block name", Required: true},
|
||||
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡). Read dashboard-block-data-config.md before creating.", Required: true},
|
||||
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true},
|
||||
{Name: "data-config", Desc: "data config JSON object (table_name, series, count_all, group_by, filter, etc.)"},
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
||||
@@ -35,7 +36,11 @@ var BaseDashboardBlockCreate = common.Shortcut{
|
||||
}
|
||||
raw := runtime.Str("data-config")
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil // 允许无 data_config 的创建(某些类型可先创建后配置)
|
||||
// text 类型必须提供 data-config(含 text 内容)
|
||||
if strings.ToLower(runtime.Str("type")) == "text" {
|
||||
return fmt.Errorf("text 类型组件必须提供 data-config,包含必填字段 text")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cfg, err := parseJSONObject(pc, raw, "data-config")
|
||||
if err != nil {
|
||||
|
||||
@@ -24,7 +24,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{
|
||||
dashboardIDFlag(true),
|
||||
blockIDFlag(true),
|
||||
{Name: "name", Desc: "new block name"},
|
||||
{Name: "data-config", Desc: "data config JSON: table_name, series|count_all (mutually exclusive), group_by, filter. See dashboard-block-data-config.md for details."},
|
||||
{Name: "data-config", Desc: "data config JSON. For chart types: table_name, series|count_all, group_by, filter. For text type: text (markdown supported). See dashboard-block-data-config.md for details."},
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
||||
},
|
||||
@@ -42,9 +42,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
norm := normalizeDataConfig(cfg)
|
||||
if errs := validateBlockDataConfig("", norm); len(errs) > 0 { // update 时不强校验类型特性
|
||||
return formatDataConfigErrors(errs)
|
||||
}
|
||||
// update 时不做强类型校验(不传 type),让后端验证具体字段
|
||||
b, _ := json.Marshal(norm)
|
||||
_ = runtime.Cmd.Flags().Set("data-config", string(b))
|
||||
return nil
|
||||
|
||||
@@ -10,14 +10,17 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// dashboardIDFlag returns a Flag for dashboard ID.
|
||||
func dashboardIDFlag(required bool) common.Flag {
|
||||
return common.Flag{Name: "dashboard-id", Desc: "dashboard ID", Required: required}
|
||||
}
|
||||
|
||||
// blockIDFlag returns a Flag for dashboard block ID.
|
||||
func blockIDFlag(required bool) common.Flag {
|
||||
return common.Flag{Name: "block-id", Desc: "dashboard block ID", Required: required}
|
||||
}
|
||||
|
||||
// dryRunDashboardBase returns a base DryRunAPI with common dashboard parameters set.
|
||||
func dryRunDashboardBase(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
@@ -25,6 +28,7 @@ func dryRunDashboardBase(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
// dryRunDashboardList returns a DryRunAPI for listing dashboards.
|
||||
func dryRunDashboardList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" {
|
||||
@@ -38,11 +42,13 @@ func dryRunDashboardList(_ context.Context, runtime *common.RuntimeContext) *com
|
||||
Params(params)
|
||||
}
|
||||
|
||||
// dryRunDashboardGet returns a DryRunAPI for getting a dashboard.
|
||||
func dryRunDashboardGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunDashboardBase(runtime).
|
||||
GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id")
|
||||
}
|
||||
|
||||
// dryRunDashboardCreate returns a DryRunAPI for creating a dashboard.
|
||||
func dryRunDashboardCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" {
|
||||
@@ -53,6 +59,7 @@ func dryRunDashboardCreate(_ context.Context, runtime *common.RuntimeContext) *c
|
||||
Body(body)
|
||||
}
|
||||
|
||||
// dryRunDashboardUpdate returns a DryRunAPI for updating a dashboard.
|
||||
func dryRunDashboardUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
@@ -66,11 +73,13 @@ func dryRunDashboardUpdate(_ context.Context, runtime *common.RuntimeContext) *c
|
||||
Body(body)
|
||||
}
|
||||
|
||||
// dryRunDashboardDelete returns a DryRunAPI for deleting a dashboard.
|
||||
func dryRunDashboardDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunDashboardBase(runtime).
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id")
|
||||
}
|
||||
|
||||
// dryRunDashboardBlockList returns a DryRunAPI for listing dashboard blocks.
|
||||
func dryRunDashboardBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" {
|
||||
@@ -84,6 +93,7 @@ func dryRunDashboardBlockList(_ context.Context, runtime *common.RuntimeContext)
|
||||
Params(params)
|
||||
}
|
||||
|
||||
// dryRunDashboardBlockGet returns a DryRunAPI for getting a dashboard block.
|
||||
func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" {
|
||||
@@ -94,6 +104,7 @@ func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext)
|
||||
Params(params)
|
||||
}
|
||||
|
||||
// dryRunDashboardBlockCreate returns a DryRunAPI for creating a dashboard block.
|
||||
func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body := map[string]interface{}{}
|
||||
@@ -119,6 +130,7 @@ func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContex
|
||||
Body(body)
|
||||
}
|
||||
|
||||
// dryRunDashboardBlockUpdate returns a DryRunAPI for updating a dashboard block.
|
||||
func dryRunDashboardBlockUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body := map[string]interface{}{}
|
||||
@@ -140,6 +152,7 @@ func dryRunDashboardBlockUpdate(_ context.Context, runtime *common.RuntimeContex
|
||||
Body(body)
|
||||
}
|
||||
|
||||
// dryRunDashboardBlockDelete returns a DryRunAPI for deleting a dashboard block.
|
||||
func dryRunDashboardBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunDashboardBase(runtime).
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id")
|
||||
@@ -147,6 +160,7 @@ func dryRunDashboardBlockDelete(_ context.Context, runtime *common.RuntimeContex
|
||||
|
||||
// ── Dashboard CRUD ──────────────────────────────────────────────────
|
||||
|
||||
// executeDashboardList lists all dashboards in a base.
|
||||
func executeDashboardList(runtime *common.RuntimeContext) error {
|
||||
params := map[string]interface{}{}
|
||||
if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" {
|
||||
@@ -163,6 +177,7 @@ func executeDashboardList(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardGet retrieves a dashboard by ID.
|
||||
func executeDashboardGet(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id")), nil, nil)
|
||||
if err != nil {
|
||||
@@ -172,6 +187,7 @@ func executeDashboardGet(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardCreate creates a new dashboard.
|
||||
func executeDashboardCreate(runtime *common.RuntimeContext) error {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" {
|
||||
@@ -185,6 +201,7 @@ func executeDashboardCreate(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardUpdate updates an existing dashboard.
|
||||
func executeDashboardUpdate(runtime *common.RuntimeContext) error {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
@@ -201,6 +218,7 @@ func executeDashboardUpdate(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardDelete deletes a dashboard by ID.
|
||||
func executeDashboardDelete(runtime *common.RuntimeContext) error {
|
||||
_, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id")), nil, nil)
|
||||
if err != nil {
|
||||
@@ -212,6 +230,7 @@ func executeDashboardDelete(runtime *common.RuntimeContext) error {
|
||||
|
||||
// ── Dashboard Block CRUD ────────────────────────────────────────────
|
||||
|
||||
// executeDashboardBlockList lists all blocks in a dashboard.
|
||||
func executeDashboardBlockList(runtime *common.RuntimeContext) error {
|
||||
params := map[string]interface{}{}
|
||||
if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" {
|
||||
@@ -228,6 +247,7 @@ func executeDashboardBlockList(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardBlockGet retrieves a dashboard block by ID.
|
||||
func executeDashboardBlockGet(runtime *common.RuntimeContext) error {
|
||||
params := map[string]interface{}{}
|
||||
if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" {
|
||||
@@ -241,6 +261,7 @@ func executeDashboardBlockGet(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardBlockCreate creates a new dashboard block.
|
||||
func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body := map[string]interface{}{}
|
||||
@@ -271,6 +292,7 @@ func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardBlockUpdate updates an existing dashboard block.
|
||||
func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body := map[string]interface{}{}
|
||||
@@ -297,6 +319,7 @@ func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardBlockDelete deletes a dashboard block by ID.
|
||||
func executeDashboardBlockDelete(runtime *common.RuntimeContext) error {
|
||||
_, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), nil, nil)
|
||||
if err != nil {
|
||||
@@ -305,3 +328,36 @@ func executeDashboardBlockDelete(runtime *common.RuntimeContext) error {
|
||||
runtime.Out(map[string]interface{}{"deleted": true, "block_id": runtime.Str("block-id")}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Dashboard Arrange ────────────────────────────────────────────────
|
||||
|
||||
// dryRunDashboardArrange returns a DryRunAPI for the dashboard arrange endpoint.
|
||||
func dryRunDashboardArrange(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" {
|
||||
params["user_id_type"] = userIDType
|
||||
}
|
||||
return dryRunDashboardBase(runtime).
|
||||
POST("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/arrange").
|
||||
Params(params).
|
||||
Body(map[string]interface{}{})
|
||||
}
|
||||
|
||||
// executeDashboardArrange sends a POST request to auto-arrange dashboard blocks layout.
|
||||
func executeDashboardArrange(runtime *common.RuntimeContext) error {
|
||||
params := map[string]interface{}{}
|
||||
if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" {
|
||||
params["user_id_type"] = userIDType
|
||||
}
|
||||
// 请求体为空对象,由服务端智能重排
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "arrange"), params, map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
data["arranged"] = true
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseFieldCreate = common.Shortcut{
|
||||
{Name: "json", Desc: "field property JSON object", Required: true},
|
||||
{Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Status","type":"text"}'`,
|
||||
"Agent hint: use the lark-base skill's field-create guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFieldCreate(runtime)
|
||||
},
|
||||
|
||||
@@ -134,7 +134,7 @@ func executeFieldList(runtime *common.RuntimeContext) error {
|
||||
if total == 0 {
|
||||
total = len(fields)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"items": simplifyFields(fields), "offset": offset, "limit": limit, "count": len(fields), "total": total}, nil)
|
||||
runtime.Out(map[string]interface{}{"fields": fields, "total": total}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ var BaseFieldUpdate = common.Shortcut{
|
||||
{Name: "json", Desc: "field property JSON object", Required: true},
|
||||
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Status","type":"text"}'`,
|
||||
"Agent hint: use the lark-base skill's field-update guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFieldUpdate(runtime)
|
||||
},
|
||||
|
||||
@@ -379,7 +379,18 @@ func baseV3Path(parts ...string) string {
|
||||
func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
for k, v := range params {
|
||||
queryParams.Set(k, fmt.Sprintf("%v", v))
|
||||
switch val := v.(type) {
|
||||
case []string:
|
||||
for _, item := range val {
|
||||
queryParams.Add(k, item)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range val {
|
||||
queryParams.Add(k, fmt.Sprintf("%v", item))
|
||||
}
|
||||
default:
|
||||
queryParams.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: strings.ToUpper(method),
|
||||
@@ -662,45 +673,6 @@ func viewName(view map[string]interface{}) string {
|
||||
return v
|
||||
}
|
||||
|
||||
func viewType(view map[string]interface{}) string {
|
||||
if v, _ := view["type"].(string); v != "" {
|
||||
return v
|
||||
}
|
||||
v, _ := view["view_type"].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func simplifyFields(fields []map[string]interface{}) []interface{} {
|
||||
items := make([]interface{}, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
entry := map[string]interface{}{
|
||||
"field_id": fieldID(field),
|
||||
"field_name": fieldName(field),
|
||||
"type": fieldTypeName(field),
|
||||
}
|
||||
if style, ok := field["style"].(map[string]interface{}); ok && len(style) > 0 {
|
||||
entry["style"] = style
|
||||
}
|
||||
if multiple, ok := field["multiple"].(bool); ok {
|
||||
entry["multiple"] = multiple
|
||||
}
|
||||
items = append(items, entry)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func simplifyViews(views []map[string]interface{}) []interface{} {
|
||||
items := make([]interface{}, 0, len(views))
|
||||
for _, view := range views {
|
||||
items = append(items, map[string]interface{}{
|
||||
"view_id": viewID(view),
|
||||
"view_name": viewName(view),
|
||||
"view_type": viewType(view),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func canonicalValue(v interface{}) string {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
@@ -984,6 +956,8 @@ func sleepBetweenBatches(index int, total int) {
|
||||
|
||||
// ── Dashboard Block data_config normalization & validation ───────────
|
||||
|
||||
// normalizeDataConfig normalizes data_config fields for dashboard blocks.
|
||||
// It converts series[].rollup to uppercase and group_by[].sort fields to lowercase.
|
||||
func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
@@ -1025,8 +999,21 @@ func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} {
|
||||
return out
|
||||
}
|
||||
|
||||
// validateBlockDataConfig validates data_config based on block type.
|
||||
// For text type, it checks for the presence of text field.
|
||||
// For chart types, it validates table_name, series/count_all, group_by, and filter fields.
|
||||
func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []string {
|
||||
var errs []string
|
||||
|
||||
// text 类型特殊校验:只需要有 text 字段即可
|
||||
if strings.ToLower(blockType) == "text" {
|
||||
if txt, _ := cfg["text"].(string); strings.TrimSpace(txt) == "" {
|
||||
errs = append(errs, "text 类型组件缺少必填字段 text")
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// 图表类型通用校验
|
||||
// table_name 必填
|
||||
if tn, _ := cfg["table_name"].(string); strings.TrimSpace(tn) == "" {
|
||||
errs = append(errs, "缺少必填字段 table_name")
|
||||
|
||||
@@ -198,7 +198,7 @@ func TestRecordAndChunkHelpers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndSimplifyHelpers(t *testing.T) {
|
||||
func TestResolveHelpers(t *testing.T) {
|
||||
fields := []map[string]interface{}{{"id": "fld_1", "name": "Name", "type": "text"}, {"field_id": "fld_2", "field_name": "Age", "type": "number", "multiple": true}}
|
||||
tables := []map[string]interface{}{{"id": "tbl_1", "name": "Orders"}}
|
||||
views := []map[string]interface{}{{"id": "vew_1", "name": "Main", "type": "grid"}}
|
||||
@@ -214,14 +214,6 @@ func TestResolveAndSimplifyHelpers(t *testing.T) {
|
||||
if _, err := resolveViewRef(views, "Missing"); err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
simplifiedFields := simplifyFields(fields)
|
||||
if len(simplifiedFields) != 2 {
|
||||
t.Fatalf("simplifiedFields=%v", simplifiedFields)
|
||||
}
|
||||
simplifiedViews := simplifyViews(views)
|
||||
if len(simplifiedViews) != 1 {
|
||||
t.Fatalf("simplifiedViews=%v", simplifiedViews)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterAndSortHelpers(t *testing.T) {
|
||||
@@ -314,9 +306,6 @@ func TestIdentifierAndValueHelpers(t *testing.T) {
|
||||
if viewName(map[string]interface{}{"view_name": "Main"}) != "Main" {
|
||||
t.Fatalf("viewName alt key failed")
|
||||
}
|
||||
if viewType(map[string]interface{}{"view_type": "grid"}) != "grid" {
|
||||
t.Fatalf("viewType alt key failed")
|
||||
}
|
||||
if !valueEmpty(nil) || !valueEmpty(" ") || !valueEmpty([]interface{}{}) || !valueEmpty(map[string]interface{}{}) {
|
||||
t.Fatalf("valueEmpty empty cases failed")
|
||||
}
|
||||
|
||||
32
shortcuts/base/record_batch_create.go
Normal file
32
shortcuts/base/record_batch_create.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseRecordBatchCreate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-batch-create",
|
||||
Description: "Batch create records",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:record:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "batch create JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
|
||||
},
|
||||
DryRun: dryRunRecordBatchCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchCreate(runtime)
|
||||
},
|
||||
}
|
||||
32
shortcuts/base/record_batch_update.go
Normal file
32
shortcuts/base/record_batch_update.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseRecordBatchUpdate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-batch-update",
|
||||
Description: "Batch update records",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:record:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "batch update JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
|
||||
},
|
||||
DryRun: dryRunRecordBatchUpdate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchUpdate(runtime)
|
||||
},
|
||||
}
|
||||
@@ -19,6 +19,7 @@ var BaseRecordList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "field-id", Type: "string_array", Desc: "field ID or field name to include (repeatable)"},
|
||||
{Name: "view-id", Desc: "view ID"},
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
|
||||
|
||||
@@ -5,6 +5,8 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -15,13 +17,18 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
offset = 0
|
||||
}
|
||||
limit := common.ParseIntBounded(runtime, "limit", 1, 200)
|
||||
params := map[string]interface{}{"offset": offset, "limit": limit}
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
params["view_id"] = viewID
|
||||
params := url.Values{}
|
||||
params.Set("offset", strconv.Itoa(offset))
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
for _, field := range recordListFields(runtime) {
|
||||
params.Add("field_id", field)
|
||||
}
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
params.Set("view_id", viewID)
|
||||
}
|
||||
path := "/open-apis/base/v3/bases/:base_token/tables/:table_id/records?" + params.Encode()
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records").
|
||||
Params(params).
|
||||
GET(path).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
@@ -34,6 +41,16 @@ func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
}
|
||||
|
||||
func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/search").
|
||||
Body(body).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordUpsert(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
@@ -52,6 +69,26 @@ func dryRunRecordUpsert(_ context.Context, runtime *common.RuntimeContext) *comm
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordBatchCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_create").
|
||||
Body(body).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordBatchUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_update").
|
||||
Body(body).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
@@ -79,6 +116,10 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
return runtime.StrArray("field-id")
|
||||
}
|
||||
|
||||
func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
offset := runtime.Int("offset")
|
||||
if offset < 0 {
|
||||
@@ -86,6 +127,10 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
limit := common.ParseIntBounded(runtime, "limit", 1, 200)
|
||||
params := map[string]interface{}{"offset": offset, "limit": limit}
|
||||
fields := recordListFields(runtime)
|
||||
if len(fields) > 0 {
|
||||
params["field_id"] = fields
|
||||
}
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
params["view_id"] = viewID
|
||||
}
|
||||
@@ -106,6 +151,20 @@ func executeRecordGet(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordSearch(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "search"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordUpsert(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
@@ -130,6 +189,36 @@ func executeRecordUpsert(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordBatchCreate(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_create"), nil, body)
|
||||
data, err := handleBaseAPIResult(result, err, "batch create records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordBatchUpdate(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_update"), nil, body)
|
||||
data, err := handleBaseAPIResult(result, err, "batch update records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordDelete(runtime *common.RuntimeContext) error {
|
||||
_, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil)
|
||||
if err != nil {
|
||||
|
||||
32
shortcuts/base/record_search.go
Normal file
32
shortcuts/base/record_search.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseRecordSearch = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-search",
|
||||
Description: "Search records in a table",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:record:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "record search JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
|
||||
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
|
||||
},
|
||||
DryRun: dryRunRecordSearch,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordSearch(runtime)
|
||||
},
|
||||
}
|
||||
@@ -22,8 +22,9 @@ var BaseRecordUpsert = common.Shortcut{
|
||||
recordRefFlag(false),
|
||||
{Name: "json", Desc: "record JSON object", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
Tips: []string{
|
||||
`Example: --json '{"Name":"Alice"}'`,
|
||||
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
|
||||
},
|
||||
DryRun: dryRunRecordUpsert,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -25,6 +25,8 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseViewDelete,
|
||||
BaseViewGetFilter,
|
||||
BaseViewSetFilter,
|
||||
BaseViewGetVisibleFields,
|
||||
BaseViewSetVisibleFields,
|
||||
BaseViewGetGroup,
|
||||
BaseViewSetGroup,
|
||||
BaseViewGetSort,
|
||||
@@ -35,8 +37,11 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseViewSetCard,
|
||||
BaseViewRename,
|
||||
BaseRecordList,
|
||||
BaseRecordSearch,
|
||||
BaseRecordGet,
|
||||
BaseRecordUpsert,
|
||||
BaseRecordBatchCreate,
|
||||
BaseRecordBatchUpdate,
|
||||
BaseRecordUploadAttachment,
|
||||
BaseRecordDelete,
|
||||
BaseRecordHistoryList,
|
||||
@@ -71,6 +76,7 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseDashboardCreate,
|
||||
BaseDashboardUpdate,
|
||||
BaseDashboardDelete,
|
||||
BaseDashboardArrange,
|
||||
BaseDashboardBlockList,
|
||||
BaseDashboardBlockGet,
|
||||
BaseDashboardBlockCreate,
|
||||
|
||||
@@ -68,11 +68,7 @@ func executeTableList(runtime *common.RuntimeContext) error {
|
||||
if total == 0 {
|
||||
total = len(tables)
|
||||
}
|
||||
items := make([]interface{}, 0, len(tables))
|
||||
for _, table := range tables {
|
||||
items = append(items, map[string]interface{}{"table_id": tableID(table), "table_name": tableNameFromMap(table)})
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"items": items, "offset": offset, "limit": limit, "count": len(items), "total": total}, nil)
|
||||
runtime.Out(map[string]interface{}{"tables": tables, "total": total}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -93,8 +89,8 @@ func executeTableGet(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"table": table,
|
||||
"fields": simplifyFields(fields),
|
||||
"views": simplifyViews(views),
|
||||
"fields": fields,
|
||||
"views": views,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ var BaseViewCreate = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "view JSON object/array", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Main","type":"grid"}'`,
|
||||
"Agent hint: use the lark-base skill's view-create guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewCreate(runtime)
|
||||
},
|
||||
|
||||
24
shortcuts/base/view_get_visible_fields.go
Normal file
24
shortcuts/base/view_get_visible_fields.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseViewGetVisibleFields = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+view-get-visible-fields",
|
||||
Description: "Get view visible fields configuration",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:view:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)},
|
||||
DryRun: dryRunViewGetVisibleFields,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeViewGetProperty(runtime, "visible_fields", "visible_fields")
|
||||
},
|
||||
}
|
||||
@@ -80,10 +80,18 @@ func dryRunViewGetFilter(_ context.Context, runtime *common.RuntimeContext) *com
|
||||
return dryRunViewGetProperty(runtime, "filter")
|
||||
}
|
||||
|
||||
func dryRunViewGetVisibleFields(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunViewGetProperty(runtime, "visible_fields")
|
||||
}
|
||||
|
||||
func dryRunViewSetFilter(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunViewSetJSONObject(runtime, "filter")
|
||||
}
|
||||
|
||||
func dryRunViewSetVisibleFields(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunViewSetJSONObject(runtime, "visible_fields")
|
||||
}
|
||||
|
||||
func dryRunViewGetGroup(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunViewGetProperty(runtime, "group")
|
||||
}
|
||||
@@ -154,7 +162,7 @@ func executeViewList(runtime *common.RuntimeContext) error {
|
||||
if total == 0 {
|
||||
total = len(views)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"items": simplifyViews(views), "offset": offset, "limit": limit, "count": len(views), "total": total}, nil)
|
||||
runtime.Out(map[string]interface{}{"views": views, "total": total}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -249,6 +257,23 @@ func executeViewSetWrapped(runtime *common.RuntimeContext, segment string, wrapp
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeViewSetVisibleFields(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
baseToken := runtime.Str("base-token")
|
||||
tableIDValue := baseTableID(runtime)
|
||||
viewRef := runtime.Str("view-id")
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := baseV3CallAny(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef, "visible_fields"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"visible_fields": data}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeViewRename(runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
tableIDValue := baseTableID(runtime)
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseViewSetCard = common.Shortcut{
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "card JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"cover_field":"fldCover"}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-card guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseViewSetFilter = common.Shortcut{
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "filter JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"logic":"and","conditions":[["fldStatus","==","Todo"]]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-filter guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
|
||||
@@ -20,7 +20,11 @@ var BaseViewSetGroup = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "group JSON object/array", Required: true},
|
||||
{Name: "json", Desc: "group JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"group_config":[{"field":"fldStatus","desc":false}]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONValue(runtime)
|
||||
|
||||
@@ -20,7 +20,11 @@ var BaseViewSetSort = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "sort JSON object/array", Required: true},
|
||||
{Name: "json", Desc: "sort_config JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"sort_config":[{"field":"fldPriority","desc":true}]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONValue(runtime)
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseViewSetTimebar = common.Shortcut{
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "timebar JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"start_time":"fldStart","end_time":"fldEnd","title":"fldTitle"}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-timebar guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
|
||||
33
shortcuts/base/view_set_visible_fields.go
Normal file
33
shortcuts/base/view_set_visible_fields.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseViewSetVisibleFields = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+view-set-visible-fields",
|
||||
Description: "Set view visible fields",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:view:write_only"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: `visible fields JSON object with "visible_fields"`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"visible_fields":["fldXXX"]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
|
||||
},
|
||||
DryRun: dryRunViewSetVisibleFields,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeViewSetVisibleFields(runtime)
|
||||
},
|
||||
}
|
||||
@@ -19,7 +19,7 @@ var BaseWorkflowCreate = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"My Workflow","steps":[...]}; or @path/to/file.json for large definitions`, Required: true},
|
||||
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"My Workflow","steps":[...]}`, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -20,7 +20,7 @@ var BaseWorkflowUpdate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
||||
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"New Title","steps":[...]}; or @path/to/file.json for large definitions`, Required: true},
|
||||
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"New Title","steps":[...]}`, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -23,6 +23,7 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
|
||||
"end_time": map[string]string{"timestamp": endTs},
|
||||
"attendee_ability": "can_modify_event",
|
||||
"free_busy_status": "busy",
|
||||
"vchat": map[string]string{"vc_type": "vc"},
|
||||
"reminders": []map[string]int{
|
||||
{"minutes": 5},
|
||||
},
|
||||
|
||||
372
shortcuts/calendar/calendar_room_find.go
Normal file
372
shortcuts/calendar/calendar_room_find.go
Normal file
@@ -0,0 +1,372 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
roomFindPath = "/open-apis/calendar/v4/freebusy/room_find"
|
||||
roomFindWorkers = 10
|
||||
flagSlot = "slot"
|
||||
flagCity = "city"
|
||||
flagBuilding = "building"
|
||||
flagFloor = "floor"
|
||||
flagRoomName = "room-name"
|
||||
flagMinCapacity = "min-capacity"
|
||||
flagMaxCapacity = "max-capacity"
|
||||
)
|
||||
|
||||
type roomFindRequest struct {
|
||||
City string `json:"city,omitempty"`
|
||||
Building string `json:"building,omitempty"`
|
||||
Floor string `json:"floor,omitempty"`
|
||||
RoomName string `json:"room_name,omitempty"`
|
||||
MinCapacity int `json:"min_capacity,omitempty"`
|
||||
MaxCapacity int `json:"max_capacity,omitempty"`
|
||||
EventStartTime string `json:"event_start_time,omitempty"`
|
||||
EventEndTime string `json:"event_end_time,omitempty"`
|
||||
AttendeeUserIDs []string `json:"attendee_user_ids,omitempty"`
|
||||
AttendeeChatIDs []string `json:"attendee_chat_ids,omitempty"`
|
||||
EventRrule string `json:"event_rrule,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
type roomFindSuggestion struct {
|
||||
RoomID string `json:"room_id,omitempty"`
|
||||
RoomName string `json:"room_name,omitempty"`
|
||||
Capacity int `json:"capacity,omitempty"`
|
||||
ReserveUntilTime string `json:"reserve_until_time,omitempty"`
|
||||
}
|
||||
|
||||
type roomFindData struct {
|
||||
AvailableRooms []*roomFindSuggestion `json:"available_rooms,omitempty"`
|
||||
}
|
||||
|
||||
type roomFindSlot struct {
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
}
|
||||
|
||||
type roomFindTimeSlot struct {
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms,omitempty"`
|
||||
}
|
||||
|
||||
type roomFindOutput struct {
|
||||
TimeSlots []*roomFindTimeSlot `json:"time_slots,omitempty"`
|
||||
}
|
||||
|
||||
func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFindSlot) ([]*roomFindSuggestion, error)) (*roomFindOutput, error) {
|
||||
if limit <= 0 {
|
||||
limit = 1
|
||||
}
|
||||
|
||||
out := &roomFindOutput{
|
||||
TimeSlots: make([]*roomFindTimeSlot, 0, len(slots)),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var firstErr error
|
||||
sem := make(chan struct{}, limit)
|
||||
|
||||
for _, slot := range slots {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(slot roomFindSlot) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
suggestions, err := fetch(slot)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
return
|
||||
}
|
||||
out.TimeSlots = append(out.TimeSlots, &roomFindTimeSlot{
|
||||
Start: slot.Start,
|
||||
End: slot.End,
|
||||
MeetingRooms: suggestions,
|
||||
})
|
||||
}(slot)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
|
||||
sort.Slice(out.TimeSlots, func(i, j int) bool {
|
||||
return out.TimeSlots[i].Start < out.TimeSlots[j].Start
|
||||
})
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseRoomFindSlots(runtime *common.RuntimeContext) ([]roomFindSlot, error) {
|
||||
rawSlots := runtime.StrArray(flagSlot)
|
||||
if len(rawSlots) == 0 {
|
||||
return nil, output.ErrValidation("specify at least one --slot")
|
||||
}
|
||||
slots := make([]roomFindSlot, 0, len(rawSlots))
|
||||
for _, raw := range rawSlots {
|
||||
parts := strings.Split(strings.TrimSpace(raw), "~")
|
||||
if len(parts) != 2 {
|
||||
return nil, output.ErrValidation("invalid --slot format %q, expected start~end", raw)
|
||||
}
|
||||
startTs, err := common.ParseTime(parts[0])
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot start time %q: %v", parts[0], err)
|
||||
}
|
||||
endTs, err := common.ParseTime(parts[1])
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot end time %q: %v", parts[1], err)
|
||||
}
|
||||
startSec, err := strconv.ParseInt(startTs, 10, 64)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
|
||||
}
|
||||
endSec, err := strconv.ParseInt(endTs, 10, 64)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
|
||||
}
|
||||
if endSec <= startSec {
|
||||
return nil, output.ErrValidation("--slot end time must be after start time: %q", raw)
|
||||
}
|
||||
startRFC3339, err := unixStringToRFC3339(startTs)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
|
||||
}
|
||||
endRFC3339, err := unixStringToRFC3339(endTs)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
|
||||
}
|
||||
slots = append(slots, roomFindSlot{Start: startRFC3339, End: endRFC3339})
|
||||
}
|
||||
return slots, nil
|
||||
}
|
||||
|
||||
func unixStringToRFC3339(ts string) (string, error) {
|
||||
sec, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return time.Unix(sec, 0).Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
func parseRoomFindAttendees(attendeesStr string, currentUserID string) ([]string, []string, error) {
|
||||
var userIDs []string
|
||||
var chatIDs []string
|
||||
seenUsers := map[string]bool{}
|
||||
seenChats := map[string]bool{}
|
||||
for _, id := range strings.Split(attendeesStr, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(id, "ou_"):
|
||||
if !seenUsers[id] {
|
||||
userIDs = append(userIDs, id)
|
||||
seenUsers[id] = true
|
||||
}
|
||||
case strings.HasPrefix(id, "oc_"):
|
||||
if !seenChats[id] {
|
||||
chatIDs = append(chatIDs, id)
|
||||
seenChats[id] = true
|
||||
}
|
||||
default:
|
||||
return nil, nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id)
|
||||
}
|
||||
}
|
||||
if currentUserID != "" && !seenUsers[currentUserID] {
|
||||
userIDs = append(userIDs, currentUserID)
|
||||
}
|
||||
return userIDs, chatIDs, nil
|
||||
}
|
||||
|
||||
func buildRoomFindBaseRequest(runtime *common.RuntimeContext) (*roomFindRequest, error) {
|
||||
req := &roomFindRequest{
|
||||
City: strings.TrimSpace(runtime.Str(flagCity)),
|
||||
Building: strings.TrimSpace(runtime.Str(flagBuilding)),
|
||||
Floor: strings.TrimSpace(runtime.Str(flagFloor)),
|
||||
RoomName: strings.TrimSpace(runtime.Str(flagRoomName)),
|
||||
MinCapacity: runtime.Int(flagMinCapacity),
|
||||
MaxCapacity: runtime.Int(flagMaxCapacity),
|
||||
Timezone: strings.TrimSpace(runtime.Str(flagTimezone)),
|
||||
EventRrule: strings.TrimSpace(runtime.Str(flagEventRrule)),
|
||||
}
|
||||
|
||||
currentUserID := ""
|
||||
if !runtime.IsBot() {
|
||||
currentUserID = runtime.UserOpenId()
|
||||
}
|
||||
attendeeUserIDs, attendeeChatIDs, err := parseRoomFindAttendees(runtime.Str(flagAttendees), currentUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.AttendeeUserIDs = attendeeUserIDs
|
||||
req.AttendeeChatIDs = attendeeChatIDs
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func callRoomFind(runtime *common.RuntimeContext, req *roomFindRequest) ([]*roomFindSuggestion, error) {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: "POST",
|
||||
ApiPath: roomFindPath,
|
||||
Body: req,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody))
|
||||
}
|
||||
|
||||
var resp = &OpenAPIResponse[*roomFindData]{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error())
|
||||
}
|
||||
|
||||
if resp.Code != 0 {
|
||||
return nil, output.ErrAPI(resp.Code, resp.Msg, resp.Data)
|
||||
}
|
||||
|
||||
if resp.Data != nil {
|
||||
return resp.Data.AvailableRooms, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var CalendarRoomFind = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+room-find",
|
||||
Description: "Find available meeting room candidates for one or more event time slots",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.free_busy:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: flagSlot, Type: "string_array", Desc: "event time slot in start~end format; repeatable"},
|
||||
{Name: flagCity, Type: "string", Desc: "meeting room city constraint"},
|
||||
{Name: flagBuilding, Type: "string", Desc: "meeting room building constraint"},
|
||||
{Name: flagFloor, Type: "string", Desc: "meeting room floor constraint (e.g., F2)"},
|
||||
{Name: flagRoomName, Type: "string", Desc: "meeting room name constraint (e.g., 木星, 02)"},
|
||||
{Name: flagMinCapacity, Type: "int", Desc: "minimum meeting room capacity"},
|
||||
{Name: flagMaxCapacity, Type: "int", Desc: "maximum meeting room capacity"},
|
||||
{Name: flagAttendees, Type: "string", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_)"},
|
||||
{Name: flagEventRrule, Type: "string", Desc: "event recurrence rule"},
|
||||
{Name: flagTimezone, Type: "string", Desc: "current time zone"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
baseReq, err := buildRoomFindBaseRequest(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
slots, err := parseRoomFindSlots(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
d := common.NewDryRunAPI()
|
||||
for _, slot := range slots {
|
||||
req := *baseReq
|
||||
req.EventStartTime = slot.Start
|
||||
req.EventEndTime = slot.End
|
||||
d.POST(roomFindPath).
|
||||
Desc(fmt.Sprintf("Lookup meeting room suggestions for %s - %s", slot.Start, slot.End)).
|
||||
Body(req)
|
||||
}
|
||||
return d
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, flag := range []string{flagCity, flagBuilding, flagFloor, flagRoomName, flagEventRrule, flagTimezone} {
|
||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := parseRoomFindSlots(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := parseRoomFindAttendees(runtime.Str(flagAttendees), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if minCapacity := runtime.Int(flagMinCapacity); minCapacity < 0 {
|
||||
return output.ErrValidation("--min-capacity must be >= 0")
|
||||
}
|
||||
if maxCapacity := runtime.Int(flagMaxCapacity); maxCapacity < 0 {
|
||||
return output.ErrValidation("--max-capacity must be >= 0")
|
||||
}
|
||||
if minCapacity, maxCapacity := runtime.Int(flagMinCapacity), runtime.Int(flagMaxCapacity); minCapacity > 0 && maxCapacity > 0 && minCapacity > maxCapacity {
|
||||
return output.ErrValidation("--min-capacity must be <= --max-capacity")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseReq, err := buildRoomFindBaseRequest(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slots, err := parseRoomFindSlots(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := collectRoomFindResults(slots, roomFindWorkers, func(slot roomFindSlot) ([]*roomFindSuggestion, error) {
|
||||
req := *baseReq
|
||||
req.EventStartTime = slot.Start
|
||||
req.EventEndTime = slot.End
|
||||
return callRoomFind(runtime, &req)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(out, &output.Meta{Count: len(out.TimeSlots)}, func(w io.Writer) {
|
||||
if len(out.TimeSlots) == 0 {
|
||||
fmt.Fprintln(w, "No meeting room suggestions available.")
|
||||
return
|
||||
}
|
||||
for _, slot := range out.TimeSlots {
|
||||
fmt.Fprintf(w, "%s - %s\n", slot.Start, slot.End)
|
||||
var rows []map[string]interface{}
|
||||
for _, room := range slot.MeetingRooms {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"room_id": room.RoomID,
|
||||
"room_name": room.RoomName,
|
||||
"capacity": room.Capacity,
|
||||
"reserve_until_time": room.ReserveUntilTime,
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
62
shortcuts/calendar/calendar_room_find_test.go
Normal file
62
shortcuts/calendar/calendar_room_find_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCollectRoomFindResults_LimitsConcurrency(t *testing.T) {
|
||||
slots := []roomFindSlot{
|
||||
{Start: "2026-03-27T14:00:00+08:00", End: "2026-03-27T15:00:00+08:00"},
|
||||
{Start: "2026-03-27T15:00:00+08:00", End: "2026-03-27T16:00:00+08:00"},
|
||||
{Start: "2026-03-27T16:00:00+08:00", End: "2026-03-27T17:00:00+08:00"},
|
||||
}
|
||||
|
||||
entered := make(chan struct{}, len(slots))
|
||||
release := make(chan struct{})
|
||||
done := make(chan *roomFindOutput, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
out, err := collectRoomFindResults(slots, 2, func(slot roomFindSlot) ([]*roomFindSuggestion, error) {
|
||||
entered <- struct{}{}
|
||||
<-release
|
||||
return []*roomFindSuggestion{{RoomName: slot.Start}}, nil
|
||||
})
|
||||
errCh <- err
|
||||
done <- out
|
||||
}()
|
||||
|
||||
for range 2 {
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for room-find workers to start")
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-entered:
|
||||
t.Fatal("room-find exceeded the configured concurrency limit")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(release)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("collectRoomFindResults returned error: %v", err)
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for room-find results")
|
||||
}
|
||||
|
||||
out := <-done
|
||||
if len(out.TimeSlots) != len(slots) {
|
||||
t.Fatalf("expected %d time slots, got %d", len(slots), len(out.TimeSlots))
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,7 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
|
||||
var CalendarSuggestion = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+suggestion",
|
||||
Description: "Intelligently suggest available meeting times to simplify scheduling",
|
||||
Description: "Intelligently suggest available time blocks based on unclear time ranges",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.free_busy:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -292,7 +292,7 @@ var CalendarSuggestion = common.Shortcut{
|
||||
Body: req,
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "request_fail", "api request fail", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
|
||||
|
||||
@@ -7,16 +7,18 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -88,6 +90,20 @@ func noLoginBotDefaultConfig() *core.CliConfig {
|
||||
}
|
||||
}
|
||||
|
||||
type missingTokenResolver struct{}
|
||||
|
||||
func (r *missingTokenResolver) ResolveToken(context.Context, credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, &credential.TokenUnavailableError{Source: "test", Type: credential.TokenTypeUAT}
|
||||
}
|
||||
|
||||
type staticAccountResolver struct {
|
||||
config *core.CliConfig
|
||||
}
|
||||
|
||||
func (r *staticAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return credential.AccountFromCliConfig(r.config), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarCreate tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -132,6 +148,26 @@ func TestCreate_CreateEventOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEventData_DefaultVChat(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("summary", "", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
cmd.Flags().String("rrule", "", "")
|
||||
cmd.Flags().Set("summary", "Team Sync")
|
||||
cmd.Flags().Set("description", "Weekly meeting")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
eventData := buildEventData(runtime, "1742515200", "1742518800")
|
||||
|
||||
vchat, ok := eventData["vchat"].(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("vchat = %T, want map[string]string", eventData["vchat"])
|
||||
}
|
||||
if got := vchat["vc_type"]; got != "vc" {
|
||||
t.Fatalf("vchat.vc_type = %q, want %q", got, "vc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithAttendees_Success(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
@@ -364,6 +400,11 @@ func TestCalendarShortcuts_RequireLoginUnlessExplicitBot(t *testing.T) {
|
||||
shortcut: CalendarFreebusy,
|
||||
args: []string{"+freebusy", "--start", "2025-03-21", "--end", "2025-03-21"},
|
||||
},
|
||||
{
|
||||
name: "room-find",
|
||||
shortcut: CalendarRoomFind,
|
||||
args: []string{"+room-find", "--slot", "2025-03-21T00:00:00+08:00~2025-03-21T01:00:00+08:00"},
|
||||
},
|
||||
{
|
||||
name: "rsvp",
|
||||
shortcut: CalendarRsvp,
|
||||
@@ -1023,6 +1064,255 @@ func TestSuggestion_APIError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarRoomFind tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRoomFind_MultiSlot_NewEventContext(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
for range 2 {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/room_find",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"available_rooms": []interface{}{
|
||||
map[string]interface{}{
|
||||
"room_id": "omm_room1",
|
||||
"room_name": "F2-02",
|
||||
"capacity": 7,
|
||||
"reserve_until_time": "2026-04-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
"--slot", "2026-03-27T16:00:00+08:00~2026-03-27T17:00:00+08:00",
|
||||
"--attendee-ids", "ou_user1,ou_user2",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"time_slots\"") {
|
||||
t.Fatalf("expected aggregated time_slots output, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomFind_RejectsDangerousChars(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
"--room-name", "F2-02\x7f",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for dangerous characters")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--room-name") {
|
||||
t.Fatalf("expected dangerous char error for --room-name, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomFind_DryRun_SplitsUserAndChatAttendees(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
"--attendee-ids", "ou_user1,oc_group1",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"attendee_user_ids"`) || !strings.Contains(out, `"ou_user1"`) || !strings.Contains(out, `"attendee_chat_ids"`) || !strings.Contains(out, `"oc_group1"`) {
|
||||
t.Fatalf("dry-run should split attendee IDs by prefix, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomFind_DryRun_IncludesStructuredLocationFields(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
"--city", "北京",
|
||||
"--building", "学清嘉创大厦B座",
|
||||
"--floor", "F2",
|
||||
"--room-name", "木星",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{`"city": "北京"`, `"building": "学清嘉创大厦B座"`, `"floor": "F2"`, `"room_name": "木星"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run should include %s, got: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomFind_RequestIncludesStructuredLocationFields(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/room_find",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"available_rooms": []interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
"--city", "北京",
|
||||
"--building", "学清嘉创大厦B座",
|
||||
"--floor", "F2",
|
||||
"--room-name", "木星",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &got); err != nil {
|
||||
t.Fatalf("unmarshal captured request: %v", err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"city": "北京",
|
||||
"building": "学清嘉创大厦B座",
|
||||
"floor": "F2",
|
||||
"room_name": "木星",
|
||||
} {
|
||||
if got[key] != want {
|
||||
t.Fatalf("expected %s=%q, got %#v", key, want, got[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomFind_RejectsInvertedOrZeroLengthSlots(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
slot string
|
||||
}{
|
||||
{
|
||||
name: "inverted",
|
||||
slot: "2026-03-27T15:00:00+08:00~2026-03-27T14:00:00+08:00",
|
||||
},
|
||||
{
|
||||
name: "zero-length",
|
||||
slot: "2026-03-27T15:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", tc.slot,
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected slot validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--slot end time must be after start time") {
|
||||
t.Fatalf("expected invalid slot range error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomFind_PreservesAuthErrorFromDoAPI(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, noLoginConfig())
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
nil,
|
||||
&staticAccountResolver{config: noLoginConfig()},
|
||||
&missingTokenResolver{},
|
||||
nil,
|
||||
)
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected auth error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected structured exit error, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("expected exit code %d, got %d (%v)", output.ExitAuth, exitErr.Code, err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("expected auth error detail, got %#v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestion_PreservesAuthErrorFromDoAPI(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, noLoginConfig())
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
nil,
|
||||
&staticAccountResolver{config: noLoginConfig()},
|
||||
&missingTokenResolver{},
|
||||
nil,
|
||||
)
|
||||
|
||||
err := mountAndRun(t, CalendarSuggestion, []string{
|
||||
"+suggestion",
|
||||
"--start", "2026-03-27T14:00:00+08:00",
|
||||
"--end", "2026-03-27T15:00:00+08:00",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected auth error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected structured exit error, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("expected exit code %d, got %d (%v)", output.ExitAuth, exitErr.Code, err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("expected auth error detail, got %#v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1087,17 +1377,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
||||
// Shortcuts() registration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShortcuts_Returns5(t *testing.T) {
|
||||
func TestShortcuts_Returns6(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 5 {
|
||||
t.Fatalf("expected 5 shortcuts, got %d", len(shortcuts))
|
||||
if len(shortcuts) != 6 {
|
||||
t.Fatalf("expected 6 shortcuts, got %d", len(shortcuts))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, s := range shortcuts {
|
||||
names[s.Command] = true
|
||||
}
|
||||
for _, want := range []string{"+agenda", "+create", "+freebusy", "+rsvp", "+suggestion"} {
|
||||
for _, want := range []string{"+agenda", "+create", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing shortcut %s", want)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarAgenda,
|
||||
CalendarCreate,
|
||||
CalendarFreebusy,
|
||||
CalendarRoomFind,
|
||||
CalendarRsvp,
|
||||
CalendarSuggestion,
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
@@ -18,17 +16,6 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var mimeToExt = map[string]string{
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
"application/pdf": ".pdf",
|
||||
"video/mp4": ".mp4",
|
||||
"text/plain": ".txt",
|
||||
}
|
||||
|
||||
var DocMediaDownload = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+media-download",
|
||||
@@ -90,19 +77,11 @@ var DocMediaDownload = common.Shortcut{
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Auto-detect extension from Content-Type
|
||||
finalPath := outputPath
|
||||
currentExt := filepath.Ext(outputPath)
|
||||
if currentExt == "" {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
mimeType := strings.Split(contentType, ";")[0]
|
||||
mimeType = strings.TrimSpace(mimeType)
|
||||
if ext, ok := mimeToExt[mimeType]; ok {
|
||||
finalPath = outputPath + ext
|
||||
} else if mediaType == "whiteboard" {
|
||||
finalPath = outputPath + ".png"
|
||||
}
|
||||
fallbackExt := ""
|
||||
if mediaType == "whiteboard" {
|
||||
fallbackExt = ".png"
|
||||
}
|
||||
finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, fallbackExt)
|
||||
|
||||
// Validate final path after extension append
|
||||
if finalPath != outputPath {
|
||||
|
||||
105
shortcuts/doc/doc_media_ext.go
Normal file
105
shortcuts/doc/doc_media_ext.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
type docMediaExtensionResolution struct {
|
||||
Ext string
|
||||
Source string
|
||||
Detail string
|
||||
}
|
||||
|
||||
var docMediaMimeToExt = map[string]string{
|
||||
"application/msword": ".doc",
|
||||
"application/pdf": ".pdf",
|
||||
"application/vnd.ms-excel": ".xls",
|
||||
"application/vnd.ms-powerpoint": ".ppt",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
||||
"application/xml": ".xml",
|
||||
"application/zip": ".zip",
|
||||
"image/bmp": ".bmp",
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/webp": ".webp",
|
||||
"text/csv": ".csv",
|
||||
"text/html": ".html",
|
||||
"text/plain": ".txt",
|
||||
"text/xml": ".xml",
|
||||
"video/mp4": ".mp4",
|
||||
}
|
||||
|
||||
func autoAppendDocMediaExtension(outputPath string, header http.Header, fallbackExt string) (string, *docMediaExtensionResolution) {
|
||||
if docMediaHasExplicitExtension(outputPath) {
|
||||
return outputPath, nil
|
||||
}
|
||||
normalizedPath := outputPath
|
||||
if filepath.Ext(outputPath) == "." {
|
||||
normalizedPath = strings.TrimSuffix(outputPath, ".")
|
||||
}
|
||||
if resolution := docMediaExtensionByContentType(header.Get("Content-Type")); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
if resolution := docMediaExtensionByContentDisposition(header); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
if fallbackExt != "" {
|
||||
return normalizedPath + fallbackExt, &docMediaExtensionResolution{
|
||||
Ext: fallbackExt,
|
||||
Source: "fallback",
|
||||
Detail: "default fallback",
|
||||
}
|
||||
}
|
||||
return outputPath, nil
|
||||
}
|
||||
|
||||
func docMediaHasExplicitExtension(path string) bool {
|
||||
ext := filepath.Ext(path)
|
||||
return ext != "" && ext != "."
|
||||
}
|
||||
|
||||
func docMediaExtensionByContentType(contentType string) *docMediaExtensionResolution {
|
||||
if contentType == "" {
|
||||
return nil
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||
}
|
||||
if ext, ok := docMediaMimeToExt[strings.ToLower(mediaType)]; ok {
|
||||
return &docMediaExtensionResolution{
|
||||
Ext: ext,
|
||||
Source: "Content-Type",
|
||||
Detail: contentType,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func docMediaExtensionByContentDisposition(header http.Header) *docMediaExtensionResolution {
|
||||
filename := strings.TrimSpace(larkcore.FileNameByHeader(header))
|
||||
if filename == "" {
|
||||
return nil
|
||||
}
|
||||
ext := filepath.Ext(filename)
|
||||
if ext == "" || ext == "." {
|
||||
return nil
|
||||
}
|
||||
return &docMediaExtensionResolution{
|
||||
Ext: ext,
|
||||
Source: "Content-Disposition",
|
||||
Detail: filename,
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
@@ -18,17 +16,6 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var previewMimeToExt = map[string]string{
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
"application/pdf": ".pdf",
|
||||
"video/mp4": ".mp4",
|
||||
"text/plain": ".txt",
|
||||
}
|
||||
|
||||
const PreviewType_SOURCE_FILE = "16"
|
||||
|
||||
var DocMediaPreview = common.Shortcut{
|
||||
@@ -82,16 +69,7 @@ var DocMediaPreview = common.Shortcut{
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
finalPath := outputPath
|
||||
currentExt := filepath.Ext(outputPath)
|
||||
if currentExt == "" {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
mimeType := strings.Split(contentType, ";")[0]
|
||||
mimeType = strings.TrimSpace(mimeType)
|
||||
if ext, ok := previewMimeToExt[mimeType]; ok {
|
||||
finalPath = outputPath + ext
|
||||
}
|
||||
}
|
||||
finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, "")
|
||||
|
||||
// Validate final path after extension append
|
||||
if finalPath != outputPath {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -285,6 +286,77 @@ func TestDocMediaDownloadRejectsHTTPErrorBeforeWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaDownloadAppendsExtensionFromContentDispositionFilename(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-disposition-app"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/tok_123/download",
|
||||
Status: 200,
|
||||
Body: []byte("a,b,c\n1,2,3\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Disposition": []string{`attachment; filename="drive_registry_config_addition.csv"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocMediaDownload, []string{
|
||||
"+media-download",
|
||||
"--token", "tok_123",
|
||||
"--output", "download",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := decodeDocCommandOutput(t, stdout)
|
||||
wantPath := mustDocSafeOutputPath(t, "download.csv")
|
||||
if got.Data.SavedPath != wantPath {
|
||||
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
|
||||
}
|
||||
if _, err := os.Stat(wantPath); err != nil {
|
||||
t.Fatalf("expected downloaded file at %q: %v", wantPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaDownloadAppendsExtensionForTrailingDotOutput(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-trailing-dot-app"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/tok_123/download",
|
||||
Status: 200,
|
||||
Body: []byte("a,b,c\n1,2,3\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"text/csv; charset=utf-8"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocMediaDownload, []string{
|
||||
"+media-download",
|
||||
"--token", "tok_123",
|
||||
"--output", "typed.",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := decodeDocCommandOutput(t, stdout)
|
||||
wantPath := mustDocSafeOutputPath(t, "typed.csv")
|
||||
if got.Data.SavedPath != wantPath {
|
||||
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
|
||||
}
|
||||
if _, err := os.Stat(wantPath); err != nil {
|
||||
t.Fatalf("expected downloaded file at %q: %v", wantPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaPreviewDryRunUsesMediaEndpoint(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "docs +media-preview"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
@@ -371,6 +443,113 @@ func TestDocMediaPreviewRejectsHTTPErrorBeforeWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaPreviewAppendsExtensionFromRFC5987Filename(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-disposition-app"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/tok_123/preview_download?preview_type=" + PreviewType_SOURCE_FILE,
|
||||
Status: 200,
|
||||
Body: []byte("a,b,c\n1,2,3\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Disposition": []string{`attachment; filename*=UTF-8''drive_registry_config_addition.csv`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocMediaPreview, []string{
|
||||
"+media-preview",
|
||||
"--token", "tok_123",
|
||||
"--output", "preview",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := decodeDocCommandOutput(t, stdout)
|
||||
wantPath := mustDocSafeOutputPath(t, "preview.csv")
|
||||
if got.Data.SavedPath != wantPath {
|
||||
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
|
||||
}
|
||||
if _, err := os.Stat(wantPath); err != nil {
|
||||
t.Fatalf("expected preview file at %q: %v", wantPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaPreviewAppendsExtensionForTrailingDotOutput(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-trailing-dot-app"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/tok_123/preview_download?preview_type=" + PreviewType_SOURCE_FILE,
|
||||
Status: 200,
|
||||
Body: []byte("a,b,c\n1,2,3\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename*=UTF-8''drive_registry_config_addition.csv`},
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocMediaPreview, []string{
|
||||
"+media-preview",
|
||||
"--token", "tok_123",
|
||||
"--output", "preview.",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := decodeDocCommandOutput(t, stdout)
|
||||
wantPath := mustDocSafeOutputPath(t, "preview.csv")
|
||||
if got.Data.SavedPath != wantPath {
|
||||
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
|
||||
}
|
||||
if _, err := os.Stat(wantPath); err != nil {
|
||||
t.Fatalf("expected preview file at %q: %v", wantPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaDownloadAppendsExtensionFromContentTypeMapping(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-content-type-app"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/tok_123/download",
|
||||
Status: 200,
|
||||
Body: []byte("a,b,c\n1,2,3\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"text/csv; charset=utf-8"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocMediaDownload, []string{
|
||||
"+media-download",
|
||||
"--token", "tok_123",
|
||||
"--output", "typed",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := decodeDocCommandOutput(t, stdout)
|
||||
wantPath := mustDocSafeOutputPath(t, "typed.csv")
|
||||
if got.Data.SavedPath != wantPath {
|
||||
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
|
||||
}
|
||||
if _, err := os.Stat(wantPath); err != nil {
|
||||
t.Fatalf("expected downloaded file at %q: %v", wantPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
type docDryRunOutput struct {
|
||||
Description string `json:"description"`
|
||||
API []struct {
|
||||
@@ -381,6 +560,15 @@ type docDryRunOutput struct {
|
||||
} `json:"api"`
|
||||
}
|
||||
|
||||
type docCommandOutput struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
SavedPath string `json:"saved_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ContentType string `json:"content_type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func writeSizedDocTestFile(t *testing.T, name string, size int64) {
|
||||
t.Helper()
|
||||
|
||||
@@ -410,3 +598,23 @@ func decodeDocDryRun(t *testing.T, dryAPI *common.DryRunAPI) docDryRunOutput {
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func decodeDocCommandOutput(t *testing.T, stdout *bytes.Buffer) docCommandOutput {
|
||||
t.Helper()
|
||||
|
||||
var out docCommandOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode command output: %v; output=%s", err, stdout.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mustDocSafeOutputPath(t *testing.T, output string) string {
|
||||
t.Helper()
|
||||
|
||||
path, err := validate.SafeOutputPath(output)
|
||||
if err != nil {
|
||||
t.Fatalf("SafeOutputPath(%q) error: %v", output, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -102,6 +102,9 @@ func TestResolveMarkdownAsPost(t *testing.T) {
|
||||
if !strings.Contains(got, `"tag":"md"`) {
|
||||
t.Fatalf("resolveMarkdownAsPost() = %q, want post payload", got)
|
||||
}
|
||||
if !strings.Contains(got, `"tag":"text"`) {
|
||||
t.Fatalf("resolveMarkdownAsPost() = %q, want segmented blank-line text paragraph", got)
|
||||
}
|
||||
if !strings.Contains(got, `#### Title`) || !strings.Contains(got, `##### Subtitle`) {
|
||||
t.Fatalf("resolveMarkdownAsPost() = %q, want optimized heading levels", got)
|
||||
}
|
||||
|
||||
@@ -764,25 +764,49 @@ func readMp4Duration(f fileio.File, fileSize int64) int64 {
|
||||
// 5. Compress excess blank lines
|
||||
// 6. Strip invalid image references (keep only img_xxx keys)
|
||||
var (
|
||||
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
|
||||
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
|
||||
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
|
||||
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
|
||||
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
|
||||
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
|
||||
reExcessNL = regexp.MustCompile(`\n{3,}`)
|
||||
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
|
||||
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
|
||||
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
|
||||
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
|
||||
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
|
||||
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
|
||||
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
|
||||
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
|
||||
reExcessNL = regexp.MustCompile(`\n{3,}`)
|
||||
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
|
||||
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
|
||||
reBlankLineSeparator = regexp.MustCompile(`\n(?:[ \t]*\n)+`)
|
||||
)
|
||||
|
||||
func optimizeMarkdownStyle(text string) string {
|
||||
const mark = "___CB_"
|
||||
const (
|
||||
markdownCodeBlockPlaceholder = "___CB_"
|
||||
postBlankLinePlaceholder = "\u200B"
|
||||
)
|
||||
|
||||
type markdownPart struct {
|
||||
text string
|
||||
newlineCount int
|
||||
isSeparator bool
|
||||
}
|
||||
|
||||
func protectMarkdownCodeBlocks(text string) (string, []string) {
|
||||
var codeBlocks []string
|
||||
r := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
|
||||
protected := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
|
||||
idx := len(codeBlocks)
|
||||
codeBlocks = append(codeBlocks, m)
|
||||
return fmt.Sprintf("%s%d___", mark, idx)
|
||||
return fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, idx)
|
||||
})
|
||||
return protected, codeBlocks
|
||||
}
|
||||
|
||||
func restoreMarkdownCodeBlocks(text string, codeBlocks []string) string {
|
||||
restored := text
|
||||
for i, block := range codeBlocks {
|
||||
restored = strings.Replace(restored, fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, i), block, 1)
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
func optimizeMarkdownStyle(text string) string {
|
||||
r, codeBlocks := protectMarkdownCodeBlocks(text)
|
||||
|
||||
// Only downgrade when original text has H1~H3; order matters (H2~H6 first).
|
||||
if reHasH1toH3.MatchString(text) {
|
||||
@@ -795,9 +819,7 @@ func optimizeMarkdownStyle(text string) string {
|
||||
r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2")
|
||||
r = reTableAfter.ReplaceAllString(r, "$1\n")
|
||||
|
||||
for i, block := range codeBlocks {
|
||||
r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), block, 1)
|
||||
}
|
||||
r = restoreMarkdownCodeBlocks(r, codeBlocks)
|
||||
|
||||
r = reExcessNL.ReplaceAllString(r, "\n\n")
|
||||
|
||||
@@ -816,12 +838,109 @@ func optimizeMarkdownStyle(text string) string {
|
||||
return r
|
||||
}
|
||||
|
||||
func shouldUseSegmentedPost(markdown string) bool {
|
||||
protected, _ := protectMarkdownCodeBlocks(markdown)
|
||||
return reBlankLineSeparator.MatchString(protected)
|
||||
}
|
||||
|
||||
func splitMarkdownByBlankLines(markdown string) []markdownPart {
|
||||
protected, codeBlocks := protectMarkdownCodeBlocks(markdown)
|
||||
locs := reBlankLineSeparator.FindAllStringIndex(protected, -1)
|
||||
if len(locs) == 0 {
|
||||
return []markdownPart{{text: markdown}}
|
||||
}
|
||||
|
||||
parts := make([]markdownPart, 0, len(locs)*2+1)
|
||||
last := 0
|
||||
for _, loc := range locs {
|
||||
if loc[0] > last {
|
||||
content := restoreMarkdownCodeBlocks(protected[last:loc[0]], codeBlocks)
|
||||
if content != "" {
|
||||
parts = append(parts, markdownPart{text: content})
|
||||
}
|
||||
}
|
||||
separator := protected[loc[0]:loc[1]]
|
||||
parts = append(parts, markdownPart{
|
||||
isSeparator: true,
|
||||
newlineCount: strings.Count(separator, "\n"),
|
||||
})
|
||||
last = loc[1]
|
||||
}
|
||||
|
||||
if last < len(protected) {
|
||||
content := restoreMarkdownCodeBlocks(protected[last:], codeBlocks)
|
||||
if content != "" {
|
||||
parts = append(parts, markdownPart{text: content})
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return []markdownPart{{text: markdown}}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func marshalMarkdownPostContent(content [][]map[string]interface{}) string {
|
||||
payload := map[string]interface{}{
|
||||
"zh_cn": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func buildSingleMDPost(markdown string) string {
|
||||
return marshalMarkdownPostContent([][]map[string]interface{}{
|
||||
{{
|
||||
"tag": "md",
|
||||
"text": optimizeMarkdownStyle(markdown),
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func buildSegmentedPost(markdown string) string {
|
||||
parts := splitMarkdownByBlankLines(markdown)
|
||||
content := make([][]map[string]interface{}, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part.isSeparator {
|
||||
for i := 1; i < part.newlineCount; i++ {
|
||||
content = append(content, []map[string]interface{}{{
|
||||
"tag": "text",
|
||||
"text": postBlankLinePlaceholder,
|
||||
}})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if part.text == "" {
|
||||
continue
|
||||
}
|
||||
optimized := strings.Trim(optimizeMarkdownStyle(part.text), "\n")
|
||||
if optimized == "" {
|
||||
continue
|
||||
}
|
||||
content = append(content, []map[string]interface{}{{
|
||||
"tag": "md",
|
||||
"text": optimized,
|
||||
}})
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return buildSingleMDPost(markdown)
|
||||
}
|
||||
return marshalMarkdownPostContent(content)
|
||||
}
|
||||
|
||||
func buildMarkdownPostContent(markdown string) string {
|
||||
if shouldUseSegmentedPost(markdown) {
|
||||
return buildSegmentedPost(markdown)
|
||||
}
|
||||
return buildSingleMDPost(markdown)
|
||||
}
|
||||
|
||||
// wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network).
|
||||
// Used by DryRun. Output: {"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
|
||||
// Used by DryRun. Output may include md/text paragraphs when blank-line separators are present.
|
||||
func wrapMarkdownAsPost(markdown string) string {
|
||||
optimized := optimizeMarkdownStyle(markdown)
|
||||
inner, _ := json.Marshal(optimized)
|
||||
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
|
||||
return buildMarkdownPostContent(markdown)
|
||||
}
|
||||
|
||||
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\((https?://[^)\s]+)\)`)
|
||||
@@ -856,9 +975,7 @@ func wrapMarkdownAsPostForDryRun(markdown string) (content, desc string) {
|
||||
// and wraps as post format JSON. Used by Execute (makes network calls).
|
||||
func resolveMarkdownAsPost(ctx context.Context, runtime *common.RuntimeContext, markdown string) string {
|
||||
resolved := resolveMarkdownImageURLs(ctx, runtime, markdown)
|
||||
optimized := optimizeMarkdownStyle(resolved)
|
||||
inner, _ := json.Marshal(optimized)
|
||||
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
|
||||
return buildMarkdownPostContent(resolved)
|
||||
}
|
||||
|
||||
// resolveMarkdownImageURLs finds  in markdown, downloads each URL,
|
||||
|
||||
@@ -6,6 +6,7 @@ package im
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -16,6 +17,36 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func decodePostContentForTest(t *testing.T, raw string) []interface{} {
|
||||
t.Helper()
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v, raw=%s", err, raw)
|
||||
}
|
||||
locale, _ := payload["zh_cn"].(map[string]interface{})
|
||||
content, _ := locale["content"].([]interface{})
|
||||
if content == nil {
|
||||
t.Fatalf("post content missing: %#v", payload)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func decodePostParagraphForTest(t *testing.T, raw string, idx int) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
content := decodePostContentForTest(t, raw)
|
||||
if idx >= len(content) {
|
||||
t.Fatalf("paragraph index %d out of range, len=%d, raw=%s", idx, len(content), raw)
|
||||
}
|
||||
paragraph, _ := content[idx].([]interface{})
|
||||
if len(paragraph) != 1 {
|
||||
t.Fatalf("paragraph %d = %#v, want single node", idx, paragraph)
|
||||
}
|
||||
node, _ := paragraph[0].(map[string]interface{})
|
||||
return node
|
||||
}
|
||||
|
||||
func TestNormalizeAtMentions(t *testing.T) {
|
||||
input := `<at id=ou_alpha/> hi <at open_id="ou_beta"> and <at user_id=ou_gamma /> and <at email="x@example.com"/>`
|
||||
got := normalizeAtMentions(input)
|
||||
@@ -140,6 +171,16 @@ func TestWrapMarkdownAsPostForDryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPostForDryRun_SegmentedBlankLines(t *testing.T) {
|
||||
content, _ := wrapMarkdownAsPostForDryRun("hello\n\n")
|
||||
if !strings.Contains(content, ``) {
|
||||
t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want placeholder img key", content)
|
||||
}
|
||||
if !strings.Contains(content, `"tag":"text"`) {
|
||||
t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want blank-line text paragraph", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaContentWithoutUploads(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -334,15 +375,88 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
|
||||
|
||||
func TestWrapMarkdownAsPost(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("hello **world**")
|
||||
// Should produce valid JSON with post structure
|
||||
if !strings.Contains(got, `"tag":"md"`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() missing md tag: %s", got)
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
|
||||
}
|
||||
if !strings.Contains(got, `"zh_cn"`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() missing zh_cn: %s", got)
|
||||
node := decodePostParagraphForTest(t, got, 0)
|
||||
if node["tag"] != "md" {
|
||||
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
|
||||
}
|
||||
if !strings.Contains(got, "hello **world**") {
|
||||
t.Fatalf("wrapMarkdownAsPost() missing content: %s", got)
|
||||
if node["text"] != "hello **world**" {
|
||||
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldUseSegmentedPost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
want bool
|
||||
}{
|
||||
{name: "single newline", markdown: "a\nb", want: false},
|
||||
{name: "blank line", markdown: "a\n\nb", want: true},
|
||||
{name: "blank line with spaces", markdown: "a\n \nb", want: true},
|
||||
{name: "multiple blank lines", markdown: "a\n \n \n b", want: true},
|
||||
{name: "blank lines inside code block only", markdown: "```go\n\n\nfmt.Println(1)\n```\nnext", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldUseSegmentedPost(tt.markdown); got != tt.want {
|
||||
t.Fatalf("shouldUseSegmentedPost(%q) = %v, want %v", tt.markdown, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPost_SegmentedBlankLines(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("a\n\nb")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 3 {
|
||||
t.Fatalf("wrapMarkdownAsPost(a\\n\\nb) content len = %d, want 3", len(content))
|
||||
}
|
||||
|
||||
first := decodePostParagraphForTest(t, got, 0)
|
||||
if first["tag"] != "md" || first["text"] != "a" {
|
||||
t.Fatalf("first paragraph = %#v, want md/a", first)
|
||||
}
|
||||
|
||||
second := decodePostParagraphForTest(t, got, 1)
|
||||
if second["tag"] != "text" || second["text"] != postBlankLinePlaceholder {
|
||||
t.Fatalf("second paragraph = %#v, want blank text placeholder", second)
|
||||
}
|
||||
|
||||
third := decodePostParagraphForTest(t, got, 2)
|
||||
if third["tag"] != "md" || third["text"] != "b" {
|
||||
t.Fatalf("third paragraph = %#v, want md/b", third)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPost_SegmentedMultipleBlankLines(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("a\n\n\nb")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 4 {
|
||||
t.Fatalf("wrapMarkdownAsPost(a\\n\\n\\nb) content len = %d, want 4", len(content))
|
||||
}
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
node := decodePostParagraphForTest(t, got, i)
|
||||
if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder {
|
||||
t.Fatalf("blank paragraph %d = %#v, want blank text placeholder", i, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPost_SegmentedBlankLinesWithSpaces(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("a\n \nb")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 3 {
|
||||
t.Fatalf("wrapMarkdownAsPost(a\\n \\nb) content len = %d, want 3", len(content))
|
||||
}
|
||||
node := decodePostParagraphForTest(t, got, 1)
|
||||
if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder {
|
||||
t.Fatalf("middle paragraph = %#v, want blank text placeholder", node)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,10 @@ var MailTriage = common.Shortcut{
|
||||
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "table", Desc: "output format: table | json | data (both json/data output messages array only)"},
|
||||
{Name: "format", Default: "table", Desc: "output format: table | json | data (json/data output object with pagination fields)"},
|
||||
{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
|
||||
{Name: "page-size", Type: "int", Desc: "alias for --max"},
|
||||
{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},
|
||||
{Name: "filter", Desc: `exact-match condition filter (JSON). Narrow results by folder, label, sender, recipient, etc. Run --print-filter-schema to see all fields. Example: {"folder":"INBOX","from":["alice@example.com"]}`},
|
||||
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
||||
{Name: "query", Desc: `full-text keyword search across from/to/subject/body (max 50 chars). Example: "budget report"`},
|
||||
@@ -66,13 +68,21 @@ var MailTriage = common.Shortcut{
|
||||
mailbox := resolveMailboxID(runtime)
|
||||
query := runtime.Str("query")
|
||||
showLabels := runtime.Bool("labels")
|
||||
maxCount := normalizeTriageMax(runtime.Int("max"))
|
||||
maxCount := resolveTriagePageSize(runtime)
|
||||
parsed, parseErr := parseTriagePageToken(runtime.Str("page-token"))
|
||||
filter, err := parseTriageFilter(runtime.Str("filter"))
|
||||
d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter"))
|
||||
if parseErr != nil {
|
||||
return d.Set("filter_error", parseErr.Error())
|
||||
}
|
||||
if err != nil {
|
||||
return d.Set("filter_error", err.Error())
|
||||
}
|
||||
if usesTriageSearchPath(query, filter) {
|
||||
useSearch, pathErr := resolveTriagePath(parsed, query, filter)
|
||||
if pathErr != nil {
|
||||
return d.Set("filter_error", pathErr.Error())
|
||||
}
|
||||
if useSearch {
|
||||
resolvedFilter, err := resolveSearchFilter(runtime, mailbox, filter, true)
|
||||
if err != nil {
|
||||
return d.Set("filter_error", err.Error())
|
||||
@@ -81,11 +91,15 @@ var MailTriage = common.Shortcut{
|
||||
if pageSize > searchPageMax {
|
||||
pageSize = searchPageMax
|
||||
}
|
||||
searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, "", true)
|
||||
searchDesc := "search messages (auto-paginates up to --max)"
|
||||
if parsed.RawToken != "" {
|
||||
searchDesc = "search messages (continues from --page-token, up to --max)"
|
||||
}
|
||||
searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, parsed.RawToken, true)
|
||||
d = d.POST(mailboxPath(mailbox, "search")).
|
||||
Params(searchParams).
|
||||
Body(searchBody).
|
||||
Desc("search messages (auto-paginates up to --max)")
|
||||
Desc(searchDesc)
|
||||
if showLabels {
|
||||
d = d.POST(mailboxPath(mailbox, "messages", "batch_get")).
|
||||
Body(map[string]interface{}{"format": "metadata", "message_ids": []string{"<message_id>"}}).
|
||||
@@ -101,12 +115,16 @@ var MailTriage = common.Shortcut{
|
||||
if pageSize > listPageMax {
|
||||
pageSize = listPageMax
|
||||
}
|
||||
listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, "", true)
|
||||
listDesc := "list message IDs (auto-paginates up to --max); batch_get with format=metadata"
|
||||
if parsed.RawToken != "" {
|
||||
listDesc = "list message IDs (continues from --page-token, up to --max); batch_get with format=metadata"
|
||||
}
|
||||
listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, parsed.RawToken, true)
|
||||
return d.GET(mailboxPath(mailbox, "messages")).
|
||||
Params(listParams).
|
||||
POST(mailboxPath(mailbox, "messages", "batch_get")).
|
||||
Body(map[string]interface{}{"format": "metadata", "message_ids": []string{"<message_id>"}}).
|
||||
Desc("list message IDs (auto-paginates up to --max); batch_get with format=metadata").
|
||||
Desc(listDesc).
|
||||
Set("resolve_note", "name→ID resolution for filter.folder/filter.label runs during execution; dry-run does not call folders/labels list APIs")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -128,16 +146,27 @@ var MailTriage = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maxCount := normalizeTriageMax(runtime.Int("max"))
|
||||
maxCount := resolveTriagePageSize(runtime)
|
||||
parsed, err := parseTriagePageToken(runtime.Str("page-token"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var messages []map[string]interface{}
|
||||
var hasMore bool
|
||||
var nextPageToken string
|
||||
|
||||
if usesTriageSearchPath(query, filter) {
|
||||
useSearch, err := resolveTriagePath(parsed, query, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if useSearch {
|
||||
resolvedFilter, err := resolveSearchFilter(runtime, mailbox, filter, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pageToken string
|
||||
pageToken := parsed.RawToken
|
||||
for len(messages) < maxCount {
|
||||
pageSize := maxCount - len(messages)
|
||||
if pageSize > searchPageMax {
|
||||
@@ -161,8 +190,12 @@ var MailTriage = common.Shortcut{
|
||||
pageHasMore, _ := searchData["has_more"].(bool)
|
||||
pageToken, _ = searchData["page_token"].(string)
|
||||
if !pageHasMore || pageToken == "" {
|
||||
hasMore = false
|
||||
nextPageToken = ""
|
||||
break
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
nextPageToken = encodeTriagePageToken("search", pageToken)
|
||||
}
|
||||
if len(messages) > maxCount {
|
||||
messages = messages[:maxCount]
|
||||
@@ -185,7 +218,7 @@ var MailTriage = common.Shortcut{
|
||||
}
|
||||
var (
|
||||
messageIDs []string
|
||||
pageToken string
|
||||
pageToken = parsed.RawToken
|
||||
)
|
||||
for len(messageIDs) < maxCount {
|
||||
pageSize := maxCount - len(messageIDs)
|
||||
@@ -209,8 +242,12 @@ var MailTriage = common.Shortcut{
|
||||
pageHasMore, _ := listData["has_more"].(bool)
|
||||
pageToken, _ = listData["page_token"].(string)
|
||||
if !pageHasMore || pageToken == "" {
|
||||
hasMore = false
|
||||
nextPageToken = ""
|
||||
break
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
nextPageToken = encodeTriagePageToken("list", pageToken)
|
||||
}
|
||||
if len(messageIDs) > maxCount {
|
||||
messageIDs = messageIDs[:maxCount]
|
||||
@@ -221,9 +258,19 @@ var MailTriage = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
if messages == nil {
|
||||
messages = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
switch outFormat {
|
||||
case "json", "data":
|
||||
output.PrintJson(runtime.IO().Out, messages)
|
||||
outData := map[string]interface{}{
|
||||
"messages": messages,
|
||||
"count": len(messages),
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
output.PrintJson(runtime.IO().Out, outData)
|
||||
default: // "table"
|
||||
if len(messages) == 0 {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
|
||||
@@ -244,6 +291,18 @@ var MailTriage = common.Shortcut{
|
||||
}
|
||||
output.PrintTable(runtime.IO().Out, rows)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "\n%d message(s)\n", len(messages))
|
||||
if hasMore && nextPageToken != "" {
|
||||
var hint strings.Builder
|
||||
hint.WriteString("next page: mail +triage")
|
||||
if query != "" {
|
||||
hint.WriteString(" --query " + shellQuote(query))
|
||||
}
|
||||
if filterStr := runtime.Str("filter"); filterStr != "" {
|
||||
hint.WriteString(" --filter " + shellQuote(filterStr))
|
||||
}
|
||||
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
|
||||
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
|
||||
}
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content")
|
||||
}
|
||||
return nil
|
||||
@@ -841,6 +900,85 @@ func buildSearchCreateTime(rng *triageTimeRange) map[string]interface{} {
|
||||
return createTime
|
||||
}
|
||||
|
||||
// shellQuote wraps a string in single quotes, escaping any embedded single quotes.
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// resolveTriagePath determines whether to use the search API path,
|
||||
// validating that --page-token prefix is consistent with query/filter params.
|
||||
//
|
||||
// Rules:
|
||||
// - No token: path decided by usesTriageSearchPath(query, filter).
|
||||
// - "search:" prefix: must not have list-only params (no query/search filter fields is OK for continuation).
|
||||
// - "list:" prefix: must not have query or search-only filter fields that would be silently ignored.
|
||||
// - Bare token (no prefix): rejected — all tokens emitted by triage carry a prefix.
|
||||
func resolveTriagePath(parsed triagePageToken, query string, filter triageFilter) (useSearch bool, err error) {
|
||||
if parsed.RawToken == "" {
|
||||
return usesTriageSearchPath(query, filter), nil
|
||||
}
|
||||
paramWantsSearch := usesTriageSearchPath(query, filter)
|
||||
switch parsed.Path {
|
||||
case "search":
|
||||
if !paramWantsSearch && (strings.TrimSpace(query) != "" || len(triageQueryFilterFields(filter)) > 0) {
|
||||
return false, fmt.Errorf("--page-token has search: prefix but current --query/--filter parameters indicate list path; remove conflicting parameters or use the correct token")
|
||||
}
|
||||
return true, nil
|
||||
case "list":
|
||||
if paramWantsSearch {
|
||||
return false, fmt.Errorf("--page-token has list: prefix but --query or --filter contains search-only fields (e.g. from/to/subject); these parameters would be silently ignored — remove them or use a search: token")
|
||||
}
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)")
|
||||
}
|
||||
}
|
||||
|
||||
// triagePageToken represents a parsed pagination token.
|
||||
type triagePageToken struct {
|
||||
Path string // "search" or "list"
|
||||
RawToken string // the actual API token
|
||||
}
|
||||
|
||||
// encodeTriagePageToken encodes a pagination token with path prefix.
|
||||
// Format: "search:abc123" or "list:abc123".
|
||||
func encodeTriagePageToken(path string, rawToken string) string {
|
||||
if rawToken == "" {
|
||||
return ""
|
||||
}
|
||||
return path + ":" + rawToken
|
||||
}
|
||||
|
||||
// parseTriagePageToken parses a token encoded by encodeTriagePageToken.
|
||||
// Returns an error for bare tokens or malformed tokens.
|
||||
func parseTriagePageToken(token string) (triagePageToken, error) {
|
||||
if token == "" {
|
||||
return triagePageToken{}, nil
|
||||
}
|
||||
idx := strings.IndexByte(token, ':')
|
||||
if idx < 0 {
|
||||
return triagePageToken{}, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)")
|
||||
}
|
||||
path := token[:idx]
|
||||
raw := token[idx+1:]
|
||||
if path != "search" && path != "list" {
|
||||
return triagePageToken{}, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix, got %q", path)
|
||||
}
|
||||
if raw == "" {
|
||||
return triagePageToken{}, fmt.Errorf("invalid --page-token: token value is empty after '%s:' prefix", path)
|
||||
}
|
||||
return triagePageToken{Path: path, RawToken: raw}, nil
|
||||
}
|
||||
|
||||
// resolveTriagePageSize returns the effective max count from --page-size or --max.
|
||||
// --page-size is an alias for --max; if both are set, --page-size takes priority.
|
||||
func resolveTriagePageSize(runtime *common.RuntimeContext) int {
|
||||
if ps := runtime.Int("page-size"); ps > 0 {
|
||||
return normalizeTriageMax(ps)
|
||||
}
|
||||
return normalizeTriageMax(runtime.Int("max"))
|
||||
}
|
||||
|
||||
func normalizeTriageMax(maxCount int) int {
|
||||
if maxCount <= 0 {
|
||||
return 20
|
||||
|
||||
@@ -967,4 +967,441 @@ func TestBuildSearchParamsPageToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveTriagePageSize ---
|
||||
|
||||
func TestResolveTriagePageSizeDefaultMax(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil) // max=0 (unset) → normalizeTriageMax returns 20
|
||||
got := resolveTriagePageSize(rt)
|
||||
if got != 20 {
|
||||
t.Fatalf("expected 20, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePageSizeFromMax(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, map[string]string{"max": "30"})
|
||||
got := resolveTriagePageSize(rt)
|
||||
if got != 30 {
|
||||
t.Fatalf("expected 30, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePageSizeFromPageSize(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, map[string]string{"page-size": "10"})
|
||||
got := resolveTriagePageSize(rt)
|
||||
if got != 10 {
|
||||
t.Fatalf("expected 10, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePageSizePageSizeOverridesMax(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, map[string]string{"max": "30", "page-size": "5"})
|
||||
got := resolveTriagePageSize(rt)
|
||||
if got != 5 {
|
||||
t.Fatalf("expected page-size=5 to override max=30, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePageSizeClamped(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, map[string]string{"page-size": "999"})
|
||||
got := resolveTriagePageSize(rt)
|
||||
if got != 400 {
|
||||
t.Fatalf("expected clamped to 400, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- page-token path validation ---
|
||||
|
||||
func TestResolveTriagePathSearchTokenContinuation(t *testing.T) {
|
||||
// search: token without --query is valid (continuation)
|
||||
useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "search:abc123"), "", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !useSearch {
|
||||
t.Fatal("search: prefix should select search path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathListTokenConflictsWithQuery(t *testing.T) {
|
||||
// list: token + --query → error (query would be silently ignored)
|
||||
_, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc123"), "hello", triageFilter{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for list: token with --query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathListTokenConflictsWithSearchFilter(t *testing.T) {
|
||||
// list: token + search-only filter field → error
|
||||
_, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc123"), "", triageFilter{From: []string{"a@b.com"}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for list: token with search-only filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathListTokenWithListFilter(t *testing.T) {
|
||||
// list: token + list-compatible filter → OK
|
||||
useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc123"), "", triageFilter{Folder: "inbox"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if useSearch {
|
||||
t.Fatal("list: prefix should select list path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathBareTokenRejected(t *testing.T) {
|
||||
// Bare tokens are rejected at parse time, not at resolveTriagePath time
|
||||
_, err := parseTriagePageToken("baretoken123")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare token without prefix")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "prefix") {
|
||||
t.Fatalf("error should mention prefix, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathEmptyToken(t *testing.T) {
|
||||
// No token → falls back to usesTriageSearchPath
|
||||
useSearch, err := resolveTriagePath(triagePageToken{}, "hello", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !useSearch {
|
||||
t.Fatal("query present → should use search path")
|
||||
}
|
||||
|
||||
useSearch, err = resolveTriagePath(triagePageToken{}, "", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if useSearch {
|
||||
t.Fatal("no query → should use list path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageTokenSearchPrefixStripped(t *testing.T) {
|
||||
raw := "search:72d98412d30aa6af"
|
||||
got := strings.TrimPrefix(raw, "search:")
|
||||
if got != "72d98412d30aa6af" {
|
||||
t.Fatalf("expected stripped token, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageTokenListPrefixStripped(t *testing.T) {
|
||||
raw := "list:FfccvoqPd_loLhtcRx8cx"
|
||||
got := strings.TrimPrefix(raw, "list:")
|
||||
if got != "FfccvoqPd_loLhtcRx8cx" {
|
||||
t.Fatalf("expected stripped token, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageTokenBareTokenRejected(t *testing.T) {
|
||||
_, err := parseTriagePageToken("FfccvoqPd_loLhtcRx8cx")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare token without prefix")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "prefix") {
|
||||
t.Fatalf("error should mention prefix requirement, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun with page-size ---
|
||||
|
||||
func TestMailTriageDryRunPageSizeOverridesMax(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"max": "50",
|
||||
"page-size": "8",
|
||||
"filter": `{"folder_id":"INBOX"}`,
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
|
||||
}
|
||||
got, ok := apis[0].Params["page_size"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("page_size type mismatch, got %#v", apis[0].Params["page_size"])
|
||||
}
|
||||
if int(got) != 8 {
|
||||
t.Fatalf("expected page_size=8 (from --page-size), got %d", int(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageDryRunSearchPathCapsPageSizeAt15(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"query": "hello",
|
||||
"page-size": "30",
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
|
||||
}
|
||||
got, ok := apis[0].Params["page_size"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("page_size type mismatch, got %#v", apis[0].Params["page_size"])
|
||||
}
|
||||
if int(got) != searchPageMax {
|
||||
t.Fatalf("expected page_size capped at %d, got %d", searchPageMax, int(got))
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun with page-token ---
|
||||
|
||||
func TestMailTriageDryRunListPathWithPageToken(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"filter": `{"folder_id":"INBOX"}`,
|
||||
"page-token": "list:abc123token",
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
|
||||
}
|
||||
got, ok := apis[0].Params["page_token"]
|
||||
if !ok {
|
||||
t.Fatalf("expected page_token in params")
|
||||
}
|
||||
if got != "abc123token" {
|
||||
t.Fatalf("expected stripped page_token='abc123token', got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageDryRunSearchPathWithPageToken(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"query": "test",
|
||||
"page-token": "search:def456token",
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
|
||||
}
|
||||
got, ok := apis[0].Params["page_token"]
|
||||
if !ok {
|
||||
t.Fatalf("expected page_token in params")
|
||||
}
|
||||
if got != "def456token" {
|
||||
t.Fatalf("expected stripped page_token='def456token', got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageDryRunBarePageTokenErrors(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"filter": `{"folder_id":"INBOX"}`,
|
||||
"page-token": "baretoken123",
|
||||
})
|
||||
dry := MailTriage.DryRun(context.Background(), runtime)
|
||||
b, _ := json.Marshal(dry)
|
||||
s := string(b)
|
||||
if !strings.Contains(s, "filter_error") {
|
||||
t.Fatalf("expected filter_error for bare token, got %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveTriagePath ---
|
||||
|
||||
func TestResolveTriagePathSearchPrefixWithoutQuery(t *testing.T) {
|
||||
useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "search:abc"), "", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !useSearch {
|
||||
t.Fatal("search: prefix should select search path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathListPrefixWithoutConflict(t *testing.T) {
|
||||
useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc"), "", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if useSearch {
|
||||
t.Fatal("list: prefix should select list path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathListPrefixWithQueryErrors(t *testing.T) {
|
||||
_, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc"), "hello", triageFilter{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for list: token with --query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathListPrefixWithSearchFilterErrors(t *testing.T) {
|
||||
_, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc"), "", triageFilter{Subject: "test"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for list: token with search-only filter field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathBareTokenErrors(t *testing.T) {
|
||||
_, err := parseTriagePageToken("baretoken")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathEmptyTokenFallsBack(t *testing.T) {
|
||||
useSearch, err := resolveTriagePath(triagePageToken{}, "", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if useSearch {
|
||||
t.Fatal("no query → should use list path")
|
||||
}
|
||||
|
||||
useSearch, err = resolveTriagePath(triagePageToken{}, "keyword", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !useSearch {
|
||||
t.Fatal("query present → should use search path")
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun: token prefix overrides path ---
|
||||
|
||||
func TestMailTriageDryRunSearchTokenWithoutQueryUsesSearchPath(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"page-token": "search:abc123",
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
|
||||
}
|
||||
if apis[0].URL != mailboxPath("me", "search") {
|
||||
t.Fatalf("search: prefix should force search path, got url %s", apis[0].URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageDryRunListTokenWithQueryErrors(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"query": "hello",
|
||||
"page-token": "list:abc123",
|
||||
})
|
||||
dry := MailTriage.DryRun(context.Background(), runtime)
|
||||
b, _ := json.Marshal(dry)
|
||||
s := string(b)
|
||||
if !strings.Contains(s, "filter_error") {
|
||||
t.Fatalf("expected filter_error for list token with query, got %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun with no page-token has no page_token param ---
|
||||
|
||||
func TestMailTriageDryRunNoPageTokenOmitsParam(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"filter": `{"folder_id":"INBOX"}`,
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
|
||||
}
|
||||
if _, ok := apis[0].Params["page_token"]; ok {
|
||||
t.Fatalf("page_token should not be present when --page-token is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Flag definition checks ---
|
||||
|
||||
func TestMailTriageFlagsIncludePageTokenAndPageSize(t *testing.T) {
|
||||
flagNames := make(map[string]bool)
|
||||
for _, fl := range MailTriage.Flags {
|
||||
flagNames[fl.Name] = true
|
||||
}
|
||||
for _, name := range []string{"page-token", "page-size", "max"} {
|
||||
if !flagNames[name] {
|
||||
t.Fatalf("expected flag --%s to be defined", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseTriagePageToken(t *testing.T, token string) triagePageToken {
|
||||
t.Helper()
|
||||
parsed, err := parseTriagePageToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTriagePageToken(%q) failed: %v", token, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// --- parseTriagePageToken / encodeTriagePageToken ---
|
||||
|
||||
func TestEncodeTriagePageToken(t *testing.T) {
|
||||
got := encodeTriagePageToken("search", "abc123")
|
||||
if got != "search:abc123" {
|
||||
t.Fatalf("expected search:abc123, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeTriagePageTokenEmpty(t *testing.T) {
|
||||
got := encodeTriagePageToken("search", "")
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty for empty raw token, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenSearch(t *testing.T) {
|
||||
parsed, err := parseTriagePageToken("search:abc123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if parsed.Path != "search" || parsed.RawToken != "abc123" {
|
||||
t.Fatalf("unexpected parsed: %+v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenList(t *testing.T) {
|
||||
parsed, err := parseTriagePageToken("list:longtoken123xyz")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if parsed.Path != "list" || parsed.RawToken != "longtoken123xyz" {
|
||||
t.Fatalf("unexpected parsed: %+v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenWithColonsInRawToken(t *testing.T) {
|
||||
// Raw token may contain colons
|
||||
parsed, err := parseTriagePageToken("search:abc:def:ghi")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if parsed.Path != "search" || parsed.RawToken != "abc:def:ghi" {
|
||||
t.Fatalf("unexpected parsed: %+v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenBareRejected(t *testing.T) {
|
||||
_, err := parseTriagePageToken("baretoken")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenEmptyRawTokenRejected(t *testing.T) {
|
||||
_, err := parseTriagePageToken("search:")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty raw token after prefix")
|
||||
}
|
||||
_, err = parseTriagePageToken("list:")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty raw token after prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenEmpty(t *testing.T) {
|
||||
parsed, err := parseTriagePageToken("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if parsed.RawToken != "" {
|
||||
t.Fatalf("expected empty parsed, got %+v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenInvalidPrefix(t *testing.T) {
|
||||
_, err := parseTriagePageToken("unknown:abc123")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -50,6 +51,18 @@ func (l *mailWatchLogger) Error(_ context.Context, args ...interface{}) {
|
||||
|
||||
var _ larkcore.Logger = (*mailWatchLogger)(nil)
|
||||
|
||||
// handleMailWatchSignal processes a shutdown signal: logs status, unsubscribes
|
||||
// mailbox events, restores default signal behavior for forced termination, and
|
||||
// cancels the watch context.
|
||||
func handleMailWatchSignal(errOut io.Writer, sig os.Signal, eventCount int64, unsubscribeWithLog func(), stopSignals func(), cancel context.CancelFunc) {
|
||||
fmt.Fprintf(errOut, "\nShutting down (signal: %v)... (received %d events)\n", sig, eventCount)
|
||||
// Restore default signal behavior so a second Ctrl+C can force terminate.
|
||||
stopSignals()
|
||||
signal.Reset(os.Interrupt, syscall.SIGTERM)
|
||||
unsubscribeWithLog()
|
||||
cancel()
|
||||
}
|
||||
|
||||
const mailEventType = "mail.user_mailbox.event.message_received_v1"
|
||||
|
||||
// promptInjectionPatterns lists known prompt injection trigger phrases.
|
||||
@@ -260,19 +273,30 @@ var MailWatch = common.Shortcut{
|
||||
})
|
||||
return unsubErr
|
||||
}
|
||||
var unsubLogOnce sync.Once
|
||||
unsubscribeWithLog := func() {
|
||||
unsubLogOnce.Do(func() {
|
||||
info("Unsubscribing mailbox events...")
|
||||
if err := unsubscribe(); err != nil {
|
||||
fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", err)
|
||||
} else {
|
||||
info("Mailbox unsubscribed.")
|
||||
}
|
||||
})
|
||||
}
|
||||
defer unsubscribeWithLog()
|
||||
|
||||
// Resolve "me" to the actual email address so we can filter events.
|
||||
mailboxFilter := mailbox
|
||||
if mailbox == "me" {
|
||||
resolved, profileErr := fetchMailboxPrimaryEmail(runtime, "me")
|
||||
if profileErr != nil {
|
||||
unsubscribe() //nolint:errcheck // best-effort cleanup; primary error is profileErr
|
||||
return enhanceProfileError(profileErr)
|
||||
}
|
||||
mailboxFilter = resolved
|
||||
}
|
||||
|
||||
eventCount := 0
|
||||
var eventCount atomic.Int64
|
||||
|
||||
handleEvent := func(data map[string]interface{}) {
|
||||
// Extract event body
|
||||
@@ -338,7 +362,7 @@ var MailWatch = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
eventCount++
|
||||
eventCount.Add(1)
|
||||
|
||||
// Prompt injection detection: warn when email body contains known injection patterns.
|
||||
// Body fields may be base64url-encoded; decode before scanning.
|
||||
@@ -425,32 +449,59 @@ var MailWatch = common.Shortcut{
|
||||
larkws.WithLogger(sdkLogger),
|
||||
)
|
||||
|
||||
watchCtx, cancelWatch := context.WithCancel(ctx)
|
||||
defer cancelWatch()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
stopSignals := func() { signal.Stop(sigCh) }
|
||||
defer stopSignals()
|
||||
|
||||
shutdownBySignal := make(chan struct{})
|
||||
var shutdownOnce sync.Once
|
||||
triggerShutdown := func() {
|
||||
shutdownOnce.Do(func() { close(shutdownBySignal) })
|
||||
cancelWatch()
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(errOut, "panic in signal handler: %v\n", r)
|
||||
triggerShutdown()
|
||||
}
|
||||
}()
|
||||
<-sigCh
|
||||
info(fmt.Sprintf("\nShutting down... (received %d events)", eventCount))
|
||||
info("Unsubscribing mailbox events...")
|
||||
if unsubErr := unsubscribe(); unsubErr != nil {
|
||||
fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr)
|
||||
} else {
|
||||
info("Mailbox unsubscribed.")
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
handleMailWatchSignal(errOut, sig, eventCount.Load(), unsubscribeWithLog, stopSignals, cancelWatch)
|
||||
triggerShutdown()
|
||||
case <-watchCtx.Done():
|
||||
return
|
||||
}
|
||||
signal.Stop(sigCh)
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
startErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
startErrCh <- cli.Start(watchCtx)
|
||||
}()
|
||||
|
||||
info("Connected. Waiting for mail events... (Ctrl+C to stop)")
|
||||
if err := cli.Start(ctx); err != nil {
|
||||
unsubscribe() //nolint:errcheck // best-effort cleanup
|
||||
return output.ErrNetwork("WebSocket connection failed: %v", err)
|
||||
select {
|
||||
case <-shutdownBySignal:
|
||||
return nil
|
||||
case err := <-startErrCh:
|
||||
if err != nil {
|
||||
select {
|
||||
case <-shutdownBySignal:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
if watchCtx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return output.ErrNetwork("WebSocket connection failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -579,6 +584,101 @@ func TestSetKeysSorted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- handleMailWatchSignal ---
|
||||
|
||||
// TestHandleMailWatchSignalUnsubscribesAndCancels verifies that all callbacks are invoked and the shutdown message is printed.
|
||||
func TestHandleMailWatchSignalUnsubscribesAndCancels(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
unsubscribed := false
|
||||
stopped := false
|
||||
canceled := false
|
||||
|
||||
handleMailWatchSignal(&buf, os.Interrupt, 3, func() {
|
||||
unsubscribed = true
|
||||
}, func() {
|
||||
stopped = true
|
||||
}, func() {
|
||||
canceled = true
|
||||
})
|
||||
|
||||
if !unsubscribed {
|
||||
t.Fatal("expected unsubscribeWithLog to be called")
|
||||
}
|
||||
if !stopped {
|
||||
t.Fatal("expected signal stop to be called")
|
||||
}
|
||||
if !canceled {
|
||||
t.Fatal("expected cancel to be called")
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Shutting down (signal: interrupt)... (received 3 events)") {
|
||||
t.Fatalf("missing shutdown message, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMailWatchSignalReportsUnsubscribeFailure verifies that unsubscribe errors are written to errOut.
|
||||
func TestHandleMailWatchSignalReportsUnsubscribeFailure(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
handleMailWatchSignal(&buf, os.Interrupt, 1, func() {
|
||||
fmt.Fprintln(&buf, "Warning: unsubscribe failed: boom")
|
||||
}, func() {}, func() {})
|
||||
|
||||
if got := buf.String(); !strings.Contains(got, "Warning: unsubscribe failed: boom") {
|
||||
t.Fatalf("expected unsubscribe warning, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMailWatchSignalPanicUnblocksShutdown verifies that a panic in unsubscribeWithLog still triggers shutdown.
|
||||
func TestHandleMailWatchSignalPanicUnblocksShutdown(t *testing.T) {
|
||||
shutdownBySignal := make(chan struct{})
|
||||
var shutdownOnce sync.Once
|
||||
_, cancelWatch := context.WithCancel(context.Background())
|
||||
triggerShutdown := func() {
|
||||
shutdownOnce.Do(func() { close(shutdownBySignal) })
|
||||
cancelWatch()
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
triggerShutdown()
|
||||
}
|
||||
}()
|
||||
<-sigCh
|
||||
// Simulate panic inside handleMailWatchSignal (e.g. unsubscribeWithLog panics)
|
||||
panic("unsubscribe exploded")
|
||||
}()
|
||||
|
||||
sigCh <- os.Interrupt
|
||||
|
||||
select {
|
||||
case <-shutdownBySignal:
|
||||
// Success: shutdown channel was closed despite the panic
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("shutdownBySignal was not closed after panic — process would hang")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMailWatchSignalCallOrder verifies callbacks execute in order: stop signals → unsubscribe → cancel.
|
||||
func TestHandleMailWatchSignalCallOrder(t *testing.T) {
|
||||
var order []string
|
||||
|
||||
handleMailWatchSignal(io.Discard, os.Interrupt, 0, func() {
|
||||
order = append(order, "unsub")
|
||||
}, func() {
|
||||
order = append(order, "stop")
|
||||
}, func() {
|
||||
order = append(order, "cancel")
|
||||
})
|
||||
|
||||
// Expected: stop → unsub → cancel
|
||||
if len(order) != 3 || order[0] != "stop" || order[1] != "unsub" || order[2] != "cancel" {
|
||||
t.Fatalf("unexpected call order: %v, want [stop unsub cancel]", order)
|
||||
}
|
||||
}
|
||||
|
||||
func assertErr(msg string) error {
|
||||
return &testErr{msg: msg}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,15 @@ import (
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
WhiteboardUpdate,
|
||||
WhiteboardUpdateOld,
|
||||
WhiteboardQuery,
|
||||
}
|
||||
}
|
||||
|
||||
type WbCliOutput struct {
|
||||
Code int `json:"code"`
|
||||
Data WbCliOutputData
|
||||
Code int `json:"code"`
|
||||
Data WbCliOutputData
|
||||
RawNodes []interface{} `json:"nodes"` // 从 whiteboard-cli -t openapi 输出的原始请求格式
|
||||
}
|
||||
|
||||
type WbCliOutputData struct {
|
||||
|
||||
376
shortcuts/whiteboard/whiteboard_query.go
Normal file
376
shortcuts/whiteboard/whiteboard_query.go
Normal file
@@ -0,0 +1,376 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
WhiteboardQueryAsImage = "image"
|
||||
WhiteboardQueryAsCode = "code"
|
||||
WhiteboardQueryAsRaw = "raw"
|
||||
)
|
||||
|
||||
type SyntaxType int
|
||||
|
||||
const (
|
||||
SyntaxTypePlantUML SyntaxType = 1
|
||||
SyntaxTypeMermaid SyntaxType = 2
|
||||
)
|
||||
|
||||
var SyntaxTypeNameMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: "plantuml",
|
||||
SyntaxTypeMermaid: "mermaid",
|
||||
}
|
||||
|
||||
var SyntaxTypeExtensionMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: ".puml",
|
||||
SyntaxTypeMermaid: ".mmd",
|
||||
}
|
||||
|
||||
func (s SyntaxType) String() string {
|
||||
return SyntaxTypeNameMap[s]
|
||||
}
|
||||
|
||||
func (s SyntaxType) ExtensionName() string {
|
||||
return SyntaxTypeExtensionMap[s]
|
||||
}
|
||||
|
||||
func (s SyntaxType) IsValid() bool {
|
||||
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
|
||||
}
|
||||
|
||||
var WhiteboardQuery = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+query",
|
||||
Description: "Query a existing whiteboard, export it as preview image or raw nodes structure.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"board:whiteboard:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
|
||||
{Name: "output_as", Desc: "output whiteboard as: image | code | raw.", Required: true},
|
||||
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as code/raw, it will output directly.", Required: false},
|
||||
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
|
||||
},
|
||||
HasFormat: true,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Check if token contains control characters
|
||||
token := runtime.Str("whiteboard-token")
|
||||
if err := validate.RejectControlChars(token, "whiteboard-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
out := runtime.Str("output")
|
||||
if out != "" {
|
||||
if err := runtime.ValidatePath(out); err != nil {
|
||||
return output.ErrValidation("invalid output path: %s", err)
|
||||
}
|
||||
}
|
||||
if out == "" && runtime.Str("output_as") == WhiteboardQueryAsImage {
|
||||
return output.ErrValidation("need a output directory to query whiteboard as image")
|
||||
}
|
||||
|
||||
as := runtime.Str("output_as")
|
||||
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
|
||||
return common.FlagErrorf("--output_as flag must be one of: image | code | raw")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
as := runtime.Str("output_as")
|
||||
token := runtime.Str("whiteboard-token")
|
||||
switch as {
|
||||
case WhiteboardQueryAsImage:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Export preview image of given whiteboard")
|
||||
case WhiteboardQueryAsCode:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract Mermaid/Plantuml code from given whiteboard")
|
||||
case WhiteboardQueryAsRaw:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract raw nodes structure from given whiteboard")
|
||||
default:
|
||||
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | code | raw")
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// 构建 API 请求
|
||||
token := runtime.Str("whiteboard-token")
|
||||
outDir := runtime.Str("output")
|
||||
as := runtime.Str("output_as")
|
||||
switch as {
|
||||
case WhiteboardQueryAsImage:
|
||||
return exportWhiteboardPreview(ctx, runtime, token, outDir)
|
||||
case WhiteboardQueryAsCode:
|
||||
return exportWhiteboardCode(runtime, token, outDir)
|
||||
case WhiteboardQueryAsRaw:
|
||||
return exportWhiteboardRaw(runtime, token, outDir)
|
||||
default:
|
||||
return output.ErrValidation("--as flag must be one of: image | code | raw")
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
|
||||
}
|
||||
// Execute API request
|
||||
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
return output.ErrNetwork(fmt.Sprintf("get whiteboard preview failed: %v", err))
|
||||
}
|
||||
// Check response status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
}
|
||||
|
||||
finalPath, size, err := saveOutputFile(outDir, ".png", wbToken, runtime, bytes.NewReader(resp.RawBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"preview_image_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "Image size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type wbNodesResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", wbToken),
|
||||
}
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork(fmt.Sprintf("get whiteboard nodes failed: %v", err))
|
||||
}
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
}
|
||||
var nodes wbNodesResp
|
||||
err = json.Unmarshal(resp.RawBody, &nodes)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard nodes failed: %v", err))
|
||||
}
|
||||
if nodes.Code != 0 {
|
||||
return nil, output.ErrAPI(nodes.Code, "get whiteboard nodes failed", fmt.Sprintf("get whiteboard nodes failed: %s", nodes.Msg))
|
||||
}
|
||||
return &nodes, nil
|
||||
}
|
||||
|
||||
type syntaxInfo struct {
|
||||
code string
|
||||
syntaxType SyntaxType
|
||||
}
|
||||
|
||||
func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var syntaxBlocks []syntaxInfo
|
||||
for _, node := range wbNodes.Data.Nodes {
|
||||
nodeMap, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntax, ok := nodeMap["syntax"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntaxMap, ok := syntax.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
code, _ := syntaxMap["code"].(string)
|
||||
var syntaxType SyntaxType
|
||||
switch v := syntaxMap["syntax_type"].(type) {
|
||||
case float64:
|
||||
syntaxType = SyntaxType(v)
|
||||
case SyntaxType:
|
||||
syntaxType = v
|
||||
}
|
||||
if code != "" && syntaxType.IsValid() {
|
||||
syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
|
||||
}
|
||||
}
|
||||
|
||||
if len(syntaxBlocks) == 0 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "no code blocks found in whiteboard",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "No code blocks found in whiteboard\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
|
||||
// 如果有需求,可以调整到导出到多个文件的模式
|
||||
if len(syntaxBlocks) > 1 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "multiple code blocks found, cannot export directly",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
block := syntaxBlocks[0]
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"code": block.code,
|
||||
"syntax_type": block.syntaxType.String(),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", block.code)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "json_error", "cannot marshal whiteboard data: %s", err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(jsonData))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
|
||||
// Step 1: Get final output path
|
||||
info, err := runtime.FileIO().Stat(outPath)
|
||||
var finalPath string
|
||||
if err == nil && info.IsDir() {
|
||||
finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
|
||||
} else {
|
||||
// Fix extension in path
|
||||
currentExt := filepath.Ext(outPath)
|
||||
if currentExt != ext {
|
||||
if currentExt != "" {
|
||||
outPath = outPath[:len(outPath)-len(currentExt)]
|
||||
}
|
||||
outPath += ext
|
||||
}
|
||||
finalPath = outPath
|
||||
}
|
||||
if err := runtime.ValidatePath(finalPath); err != nil { // double check
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
// Step 2: Check overwrite
|
||||
_, err = runtime.FileIO().Stat(finalPath)
|
||||
if err == nil {
|
||||
if !runtime.Bool("overwrite") {
|
||||
return "", 0, output.ErrValidation(fmt.Sprintf("file already exists: %s (use --overwrite to overwrite)", finalPath))
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", 0, output.Errorf(output.ExitInternal, "io_error", "cannot check file existence: %s", err)
|
||||
}
|
||||
|
||||
// Step 3: Save file
|
||||
var contentType string
|
||||
switch ext {
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".json":
|
||||
contentType = "application/json"
|
||||
case ".mmd", ".puml":
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: contentType,
|
||||
}, data)
|
||||
if err != nil {
|
||||
return "", 0, common.WrapSaveError(err, "unsafe file path", "cannot create parent directory", "cannot create file")
|
||||
}
|
||||
|
||||
return finalPath, savResult.Size(), nil
|
||||
}
|
||||
749
shortcuts/whiteboard/whiteboard_query_test.go
Normal file
749
shortcuts/whiteboard/whiteboard_query_test.go
Normal file
@@ -0,0 +1,749 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSyntaxType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
st SyntaxType
|
||||
wantStr string
|
||||
wantExt string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "PlantUML",
|
||||
st: SyntaxTypePlantUML,
|
||||
wantStr: "plantuml",
|
||||
wantExt: ".puml",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "Mermaid",
|
||||
st: SyntaxTypeMermaid,
|
||||
wantStr: "mermaid",
|
||||
wantExt: ".mmd",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "invalid type 0",
|
||||
st: SyntaxType(0),
|
||||
wantStr: "",
|
||||
wantExt: "",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "invalid type 3",
|
||||
st: SyntaxType(3),
|
||||
wantStr: "",
|
||||
wantExt: "",
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.st.String(); got != tt.wantStr {
|
||||
t.Errorf("SyntaxType.String() = %q, want %q", got, tt.wantStr)
|
||||
}
|
||||
if got := tt.st.ExtensionName(); got != tt.wantExt {
|
||||
t.Errorf("SyntaxType.ExtensionName() = %q, want %q", got, tt.wantExt)
|
||||
}
|
||||
if got := tt.st.IsValid(); got != tt.wantValid {
|
||||
t.Errorf("SyntaxType.IsValid() = %v, want %v", got, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardQuery_Validate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
chdirTemp(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
boolFlags map[string]bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid: image with output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "image",
|
||||
"output": "output.png",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid: code without output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "code",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid: raw without output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "raw",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid: image without output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "image",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid: bad output_as value",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "invalid",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid: with overwrite flag",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "code",
|
||||
"output": "output.puml",
|
||||
},
|
||||
boolFlags: map[string]bool{
|
||||
"overwrite": true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rt := newTestRuntime(tt.flags, tt.boolFlags)
|
||||
err := WhiteboardQuery.Validate(ctx, rt)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("WhiteboardQuery.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantMethod string
|
||||
wantPath string
|
||||
}{
|
||||
{
|
||||
name: "dry run image",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "image",
|
||||
"output": "output.png",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/download_as_image",
|
||||
},
|
||||
{
|
||||
name: "dry run code",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "code",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
},
|
||||
{
|
||||
name: "dry run raw",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "raw",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rt := newTestRuntime(tt.flags, nil)
|
||||
dryRun := WhiteboardQuery.DryRun(ctx, rt)
|
||||
if dryRun == nil {
|
||||
t.Fatalf("WhiteboardQuery.DryRun() returned nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Verify WhiteboardQuery is properly configured
|
||||
if WhiteboardQuery.Command != "+query" {
|
||||
t.Errorf("WhiteboardQuery.Command = %q, want \"+query\"", WhiteboardQuery.Command)
|
||||
}
|
||||
if WhiteboardQuery.Service != "whiteboard" {
|
||||
t.Errorf("WhiteboardQuery.Service = %q, want \"whiteboard\"", WhiteboardQuery.Service)
|
||||
}
|
||||
if len(WhiteboardQuery.Scopes) == 0 {
|
||||
t.Errorf("WhiteboardQuery.Scopes is empty, expected at least one scope")
|
||||
}
|
||||
if len(WhiteboardQuery.Flags) == 0 {
|
||||
t.Errorf("WhiteboardQuery.Flags is empty, expected at least one flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveOutputFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a temp dir and cd into it
|
||||
chdirTemp(t)
|
||||
|
||||
// Create a subdirectory for testing directory output
|
||||
err := os.Mkdir("testdir", 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test directory: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
outPath string
|
||||
ext string
|
||||
token string
|
||||
overwrite bool
|
||||
setupFile bool
|
||||
wantPath string
|
||||
wantErr bool
|
||||
checkPath bool
|
||||
}{
|
||||
{
|
||||
name: "path is directory",
|
||||
outPath: "testdir",
|
||||
ext: ".puml",
|
||||
token: "token123",
|
||||
overwrite: false,
|
||||
setupFile: false,
|
||||
wantPath: filepath.Join("testdir", "whiteboard_token123.puml"),
|
||||
wantErr: false,
|
||||
checkPath: true,
|
||||
},
|
||||
{
|
||||
name: "path has correct extension",
|
||||
outPath: "output.puml",
|
||||
ext: ".puml",
|
||||
token: "token123",
|
||||
overwrite: false,
|
||||
setupFile: false,
|
||||
wantPath: "output.puml",
|
||||
wantErr: false,
|
||||
checkPath: true,
|
||||
},
|
||||
{
|
||||
name: "path has different extension",
|
||||
outPath: "output.txt",
|
||||
ext: ".puml",
|
||||
token: "token123",
|
||||
overwrite: false,
|
||||
setupFile: false,
|
||||
wantPath: "output.puml",
|
||||
wantErr: false,
|
||||
checkPath: true,
|
||||
},
|
||||
{
|
||||
name: "path has no extension",
|
||||
outPath: "output",
|
||||
ext: ".json",
|
||||
token: "token123",
|
||||
overwrite: false,
|
||||
setupFile: false,
|
||||
wantPath: "output.json",
|
||||
wantErr: false,
|
||||
checkPath: true,
|
||||
},
|
||||
{
|
||||
name: "file exists without overwrite",
|
||||
outPath: "existing.txt",
|
||||
ext: ".txt",
|
||||
token: "token123",
|
||||
overwrite: false,
|
||||
setupFile: true,
|
||||
wantPath: "existing.txt",
|
||||
wantErr: true,
|
||||
checkPath: false,
|
||||
},
|
||||
{
|
||||
name: "file exists with overwrite",
|
||||
outPath: "overwrite.txt",
|
||||
ext: ".txt",
|
||||
token: "token123",
|
||||
overwrite: true,
|
||||
setupFile: true,
|
||||
wantPath: "overwrite.txt",
|
||||
wantErr: false,
|
||||
checkPath: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Setup test file if needed
|
||||
if tt.setupFile {
|
||||
err := os.WriteFile(tt.wantPath, []byte("existing content"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
defer os.Remove(tt.wantPath)
|
||||
}
|
||||
|
||||
rt := newTestRuntime(nil, map[string]bool{"overwrite": tt.overwrite})
|
||||
testData := strings.NewReader("test content")
|
||||
|
||||
gotPath, size, err := saveOutputFile(tt.outPath, tt.ext, tt.token, rt, testData)
|
||||
defer func() {
|
||||
if gotPath != "" {
|
||||
os.Remove(gotPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("saveOutputFile() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
if tt.checkPath {
|
||||
// Check if path is correct
|
||||
if tt.outPath == "testdir" {
|
||||
// For directory case, just check extension and dir
|
||||
if filepath.Ext(gotPath) != tt.ext {
|
||||
t.Errorf("saveOutputFile() extension = %q, want %q", filepath.Ext(gotPath), tt.ext)
|
||||
}
|
||||
if filepath.Dir(gotPath) != "testdir" {
|
||||
t.Errorf("saveOutputFile() dir = %q, want %q", filepath.Dir(gotPath), "testdir")
|
||||
}
|
||||
} else {
|
||||
// For file case, check exact path
|
||||
if gotPath != tt.wantPath {
|
||||
t.Errorf("saveOutputFile() path = %q, want %q", gotPath, tt.wantPath)
|
||||
}
|
||||
}
|
||||
// Check if file was written
|
||||
content, err := os.ReadFile(gotPath)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read saved file: %v", err)
|
||||
}
|
||||
if string(content) != "test content" {
|
||||
t.Errorf("File content = %q, want %q", string(content), "test content")
|
||||
}
|
||||
if size != int64(len("test content")) {
|
||||
t.Errorf("File size = %d, want %d", size, len("test content"))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, config)
|
||||
return factory, stdout, reg
|
||||
}
|
||||
|
||||
func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
// Temporarily lower risk for testing
|
||||
originalRisk := shortcut.Risk
|
||||
shortcut.Risk = "read"
|
||||
shortcut.AuthTypes = []string{"bot"}
|
||||
|
||||
parent := &cobra.Command{Use: "whiteboard"}
|
||||
shortcut.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
stdout.Reset()
|
||||
err := parent.ExecuteContext(context.Background())
|
||||
|
||||
// Restore original risk
|
||||
shortcut.Risk = originalRisk
|
||||
return err
|
||||
}
|
||||
|
||||
func TestWhiteboardQueryExecute_AsRaw(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{"id": "node1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-123", "--output_as", "raw"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); !strings.Contains(got, `"nodes"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardQueryExecute_AsCode(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
// Mock nodes API response with code block
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{
|
||||
"syntax": map[string]interface{}{
|
||||
"code": "graph TD\nA-->B",
|
||||
"syntax_type": float64(2),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-123", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardCode_EmptyNodes(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with empty nodes
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-empty/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": nil,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-empty", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardCode_NoCodeBlocks(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with no syntax blocks
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-nocode/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{"id": "node1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-nocode", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardCode_InvalidSyntaxType(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with invalid syntax type
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-invalid-syntax/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{
|
||||
"syntax": map[string]interface{}{
|
||||
"code": "some code",
|
||||
"syntax_type": float64(999), // invalid type
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-invalid-syntax", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardCode_MultipleCodeBlocks(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with multiple code blocks
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-multiple/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{
|
||||
"syntax": map[string]interface{}{
|
||||
"code": "graph TD\nA-->B",
|
||||
"syntax_type": float64(2),
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"syntax": map[string]interface{}{
|
||||
"code": "classDiagram\nclass A",
|
||||
"syntax_type": float64(2),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-multiple", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "multiple code blocks found") {
|
||||
t.Fatalf("stdout missing multiple blocks message: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardCode_SingleBlock_PlantUML_DirectOutput(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with single PlantUML code block
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-single-plantuml/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{
|
||||
"syntax": map[string]interface{}{
|
||||
"code": "@startuml\n:start;\n:process;\n@enduml",
|
||||
"syntax_type": float64(1),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-single-plantuml", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "@startuml") {
|
||||
t.Fatalf("stdout missing plantuml code: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardCode_SingleBlock_Mermaid_DirectOutput(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with single Mermaid code block
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-single-mermaid/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []interface{}{
|
||||
map[string]interface{}{
|
||||
"syntax": map[string]interface{}{
|
||||
"code": "flowchart TD\n A --> B",
|
||||
"syntax_type": float64(2),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-single-mermaid", "--output_as", "code"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "flowchart TD") {
|
||||
t.Fatalf("stdout missing mermaid code: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
chdirTemp(t)
|
||||
|
||||
// Mock download preview image API response with RawBody
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake PNG image data"),
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-preview", "--output_as", "image", "--output", "output", "--overwrite"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
// Verify the file was written with .png extension
|
||||
data, err := os.ReadFile("output.png")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "fake PNG image data" {
|
||||
t.Fatalf("image content = %q, want %q", string(data), "fake PNG image data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with empty nodes
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-empty/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": nil,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-raw-empty", "--output_as", "raw"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchWhiteboardNodes_APIError(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
// Mock nodes API response with error code
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-api-error/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 10001,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-api-error", "--output_as", "raw"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
// We expect an error here, but don't fail the test because it's testing error path
|
||||
if err == nil {
|
||||
t.Fatalf("Expected API error, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
// newTestRuntime creates a RuntimeContext with string flags for testing.
|
||||
func newTestRuntime(flags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for name := range flags {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
// Parse empty args so flags have defaults, then set values.
|
||||
cmd.ParseFlags(nil)
|
||||
for name, val := range flags {
|
||||
cmd.Flags().Set(name, val)
|
||||
}
|
||||
for name, val := range boolFlags {
|
||||
if val {
|
||||
cmd.Flags().Set(name, "true")
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// chdirTemp changes the working directory to a fresh temp directory and
|
||||
// restores it when the test finishes.
|
||||
func chdirTemp(t *testing.T) {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,137 +19,161 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
FormatRaw = "raw"
|
||||
FormatPlantUML = "plantuml"
|
||||
FormatMermaid = "mermaid"
|
||||
)
|
||||
|
||||
var formatCodeMap = map[string]int{
|
||||
FormatRaw: 0,
|
||||
FormatPlantUML: 1,
|
||||
FormatMermaid: 2,
|
||||
}
|
||||
|
||||
var wbUpdateScopes = []string{"board:whiteboard:node:read", "board:whiteboard:node:create", "board:whiteboard:node:delete"}
|
||||
var wbUpdateAuthTypes = []string{"user", "bot"}
|
||||
var skipDeleteNodesBatchSleep = false // for accelerate UT testing only
|
||||
var wbUpdateFlags = []common.Flag{
|
||||
{Name: "idempotent-token", Desc: "idempotent token to ensure the update is idempotent. Default is empty. min length is 10.", Required: false},
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update. You will need edit permission to update the whiteboard.", Required: true},
|
||||
{Name: "overwrite", Desc: "overwrite the whiteboard content, delete all existing content before update. Default is false.", Required: false, Type: "bool"},
|
||||
{Name: "source", Desc: "Input whiteboard data.", Required: true, Input: []string{common.Stdin, common.File}},
|
||||
{Name: "input_format", Desc: "format of input data: raw | plantuml | mermaid. Default is raw.", Required: false},
|
||||
}
|
||||
|
||||
func wbUpdateValidate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// 检查 token 是否包含控制字符(空字符串下自动跳过了)
|
||||
if err := validate.RejectControlChars(runtime.Str("whiteboard-token"), "whiteboard-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
itoken := runtime.Str("idempotent-token")
|
||||
if err := validate.RejectControlChars(itoken, "idempotent-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
if itoken != "" && len(itoken) < 10 {
|
||||
return common.FlagErrorf("--idempotent-token must be at least 10 characters long.")
|
||||
}
|
||||
|
||||
// 检查 --input_format 标志
|
||||
format := getFormat(runtime)
|
||||
if format != FormatRaw && format != FormatPlantUML && format != FormatMermaid {
|
||||
return common.FlagErrorf("--input_format must be one of: raw | plantuml | mermaid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getFormat 获取 format,默认返回 raw
|
||||
func getFormat(runtime *common.RuntimeContext) string {
|
||||
format := runtime.Str("input_format")
|
||||
if format == "" {
|
||||
return FormatRaw
|
||||
}
|
||||
return format
|
||||
}
|
||||
|
||||
func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// 读取输入内容
|
||||
input := runtime.Str("source")
|
||||
if input == "" {
|
||||
return common.NewDryRunAPI().Desc("read input failed: source is required")
|
||||
}
|
||||
format := getFormat(runtime)
|
||||
token := runtime.Str("whiteboard-token")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
descStr := "will call whiteboard open api to update content."
|
||||
var delNum int
|
||||
var err error
|
||||
if overwrite {
|
||||
// 还是会读取一下 whiteboard nodes,确认是否有节点要删除
|
||||
delNum, _, err = clearWhiteboardContent(ctx, runtime, token, []string{}, true)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("read whiteboard nodes failed: " + err.Error())
|
||||
}
|
||||
if delNum > 0 {
|
||||
descStr += fmt.Sprintf(" %d existing nodes deleted before update.", delNum)
|
||||
}
|
||||
}
|
||||
|
||||
desc := common.NewDryRunAPI().Desc(descStr)
|
||||
|
||||
switch format {
|
||||
case FormatRaw:
|
||||
nodes, err, _ := parseWBcliNodes([]byte(input))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
|
||||
}
|
||||
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(nodes).Desc("create all nodes of the whiteboard.")
|
||||
case FormatPlantUML, FormatMermaid:
|
||||
syntaxType := formatCodeMap[format]
|
||||
reqBody := plantumlCreateReq{
|
||||
PlantUmlCode: input,
|
||||
SyntaxType: syntaxType,
|
||||
ParseMode: 1,
|
||||
DiagramType: 0,
|
||||
}
|
||||
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc(fmt.Sprintf("create %s node on the whiteboard.", format))
|
||||
}
|
||||
|
||||
if overwrite && delNum > 0 {
|
||||
// 在 DryRun 中只记录意图,不实际拉取和计算节点
|
||||
desc.GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Desc("get all nodes of the whiteboard to delete, then filter out newly created ones.")
|
||||
desc.DELETE(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", common.MaskToken(url.PathEscape(token)))).Body("{\"ids\":[\"...\"]}").
|
||||
Desc(fmt.Sprintf("delete all old nodes of the whiteboard 100 nodes at a time. This API may be called multiple times and is not reversible. %d whiteboard nodes will be deleted while update.", delNum))
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
func wbUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("whiteboard-token")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
idempotentToken := runtime.Str("idempotent-token")
|
||||
format := getFormat(runtime)
|
||||
|
||||
input := runtime.Str("source")
|
||||
if input == "" {
|
||||
return output.ErrValidation("read input failed: source is required")
|
||||
}
|
||||
|
||||
switch format {
|
||||
case FormatRaw:
|
||||
return updateWhiteboardByRawNodes(ctx, runtime, token, []byte(input), overwrite, idempotentToken)
|
||||
case FormatPlantUML, FormatMermaid:
|
||||
return updateWhiteboardByCode(ctx, runtime, token, []byte(input), format, overwrite, idempotentToken)
|
||||
default:
|
||||
return output.ErrValidation(fmt.Sprintf("unsupported format: %s", format))
|
||||
}
|
||||
}
|
||||
|
||||
const WhiteboardUpdateDescription = "Update an existing whiteboard in lark document with mermaid, plantuml or whiteboard dsl. refer to lark-whiteboard skill for more details."
|
||||
|
||||
var WhiteboardUpdate = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+update",
|
||||
Description: WhiteboardUpdateDescription,
|
||||
Risk: "high-risk-write",
|
||||
Scopes: wbUpdateScopes,
|
||||
AuthTypes: wbUpdateAuthTypes,
|
||||
Flags: wbUpdateFlags,
|
||||
HasFormat: false, // 不使用 lark 的 format flag(使用画板内部的格式)
|
||||
Validate: wbUpdateValidate,
|
||||
DryRun: wbUpdateDryRun,
|
||||
Execute: wbUpdateExecute,
|
||||
}
|
||||
|
||||
// WhiteboardUpdateOld 向前兼容历史版本 Doc 域下的更新命令
|
||||
var WhiteboardUpdateOld = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+whiteboard-update",
|
||||
Description: "Update an existing whiteboard in lark document with whiteboard dsl. Such DSL input from stdin. refer to lark-whiteboard skill for more details.",
|
||||
Description: WhiteboardUpdateDescription,
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"board:whiteboard:node:read", "board:whiteboard:node:create", "board:whiteboard:node:delete"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "idempotent-token", Desc: "idempotent token to ensure the update is idempotent. Default is empty. min length is 10.", Required: false},
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update. You will need edit permission to update the whiteboard.", Required: true},
|
||||
{Name: "overwrite", Desc: "overwrite the whiteboard content, delete all existing content before update. Default is false.", Required: false, Type: "bool"},
|
||||
},
|
||||
HasFormat: false, // 不使用 lark 的 format flag(使用画板内部的格式)
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// 检查 token 是否包含控制字符(空字符串下自动跳过了)
|
||||
if err := validate.RejectControlChars(runtime.Str("whiteboard-token"), "whiteboard-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
itoken := runtime.Str("idempotent-token")
|
||||
if err := validate.RejectControlChars(itoken, "idempotent-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
if itoken != "" && len(itoken) < 10 {
|
||||
return common.FlagErrorf("--idempotent-token must be at least 10 characters long.")
|
||||
}
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil || (stat.Mode()&os.ModeCharDevice) != 0 {
|
||||
return output.ErrValidation("read stdin failed, please follow lark-whiteboard skill to pipe in input data")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// 读取 stdin 内容,解析为 OAPI 参数
|
||||
input, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("read stdin failed: " + err.Error())
|
||||
}
|
||||
var wbOutput WbCliOutput
|
||||
if err := json.Unmarshal(input, &wbOutput); err != nil {
|
||||
return common.NewDryRunAPI().Desc("unmarshal stdin json failed: " + err.Error())
|
||||
}
|
||||
if wbOutput.Code != 0 || wbOutput.Data.To != "openapi" {
|
||||
return common.NewDryRunAPI().Desc("whiteboard-draw failed. please check previous log.")
|
||||
}
|
||||
token := runtime.Str("whiteboard-token")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
descStr := "will call whiteboard open api to draw such DSL content."
|
||||
var delNum int
|
||||
if overwrite {
|
||||
// 还是会读取一下 whiteboard nodes,确认是否有节点要删除
|
||||
delNum, _, err = clearWhiteboardContent(ctx, runtime, token, []string{}, true)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("read whiteboard nodes failed: " + err.Error())
|
||||
}
|
||||
if delNum > 0 {
|
||||
descStr += fmt.Sprintf("%d existing nodes deleted before update.", delNum)
|
||||
}
|
||||
}
|
||||
desc := common.NewDryRunAPI().Desc(descStr)
|
||||
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(wbOutput.Data.Result).Desc("create all nodes of the whiteboard.")
|
||||
if overwrite && delNum > 0 {
|
||||
// 在 DryRun 中只记录意图,不实际拉取和计算节点
|
||||
desc.GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Desc("get all nodes of the whiteboard to delete, then filter out newly created ones.")
|
||||
desc.DELETE(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", common.MaskToken(url.PathEscape(token)))).Body("{\"ids\":[\"...\"]}").
|
||||
Desc(fmt.Sprintf("delete all old nodes of the whiteboard 100 nodes at a time. This API may be called multiple times and is not reversible. %d whiteboard nodes will be deleted while update.", delNum))
|
||||
}
|
||||
return desc
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// 检查 token
|
||||
token := runtime.Str("whiteboard-token")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
idempotentToken := runtime.Str("idempotent-token")
|
||||
// 读取 stdin 内容,解析为 OAPI 参数
|
||||
input, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return output.ErrValidation("read stdin failed: " + err.Error())
|
||||
}
|
||||
var wbOutput WbCliOutput
|
||||
if err := json.Unmarshal(input, &wbOutput); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("unmarshal stdin json failed: %v", err))
|
||||
}
|
||||
if wbOutput.Code != 0 || wbOutput.Data.To != "openapi" {
|
||||
return output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-draw failed. please check previous log.")
|
||||
}
|
||||
outData := make(map[string]string)
|
||||
// 写入画板节点
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(token)),
|
||||
Body: wbOutput.Data.Result,
|
||||
QueryParams: map[string][]string{},
|
||||
}
|
||||
if idempotentToken != "" {
|
||||
req.QueryParams["client_token"] = []string{idempotentToken}
|
||||
}
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return output.ErrNetwork(fmt.Sprintf("update whiteboard failed: %v", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
}
|
||||
var createResp createResponse
|
||||
err = json.Unmarshal(resp.RawBody, &createResp)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard create response failed: %v", err))
|
||||
}
|
||||
if createResp.Code != 0 {
|
||||
return output.ErrAPI(createResp.Code, "update whiteboard failed", fmt.Sprintf("update whiteboard failed: %s", createResp.Msg))
|
||||
}
|
||||
outData["created_node_ids"] = strings.Join(createResp.Data.NodeIDs, ",")
|
||||
// 清空画板节点,先写后删,起码新的能写进去
|
||||
if overwrite {
|
||||
numNodes, _, err := clearWhiteboardContent(ctx, runtime, token, createResp.Data.NodeIDs, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData["deleted_nodes_num"] = fmt.Sprintf("%d", numNodes)
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["deleted_nodes_num"] != "" {
|
||||
fmt.Fprintf(w, "%s existing nodes deleted.\n", outData["deleted_nodes_num"])
|
||||
}
|
||||
if outData["created_node_ids"] != "" {
|
||||
fmt.Fprintf(w, "%d new nodes created.\n", len(createResp.Data.NodeIDs))
|
||||
}
|
||||
fmt.Fprintf(w, "update whiteboard success")
|
||||
})
|
||||
return nil
|
||||
},
|
||||
Scopes: wbUpdateScopes,
|
||||
AuthTypes: wbUpdateAuthTypes,
|
||||
Flags: wbUpdateFlags,
|
||||
HasFormat: false, // 不使用 lark 的 format flag(使用画板内部的格式)
|
||||
Validate: wbUpdateValidate,
|
||||
DryRun: wbUpdateDryRun,
|
||||
Execute: wbUpdateExecute,
|
||||
}
|
||||
|
||||
type createResponse struct {
|
||||
@@ -173,7 +195,8 @@ type simpleNodeResp struct {
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Nodes []struct {
|
||||
Id string `json:"id"`
|
||||
Id string `json:"id"`
|
||||
Children []string `json:"children"`
|
||||
} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -182,6 +205,42 @@ type deleteNodeReqBody struct {
|
||||
Ids []string `json:"ids"`
|
||||
}
|
||||
|
||||
type plantumlCreateReq struct {
|
||||
PlantUmlCode string `json:"plant_uml_code"`
|
||||
SyntaxType int `json:"syntax_type"`
|
||||
DiagramType int `json:"diagram_type,omitempty"`
|
||||
ParseMode int `json:"parse_mode,omitempty"`
|
||||
}
|
||||
|
||||
type plantumlCreateResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
NodeID string `json:"node_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func parseWBcliNodes(rawjson []byte) (wbNodes interface{}, err error, isRaw bool) {
|
||||
var wbOutput WbCliOutput
|
||||
if err := json.Unmarshal(rawjson, &wbOutput); err != nil {
|
||||
return nil, output.Errorf(output.ExitValidation, "parsing", fmt.Sprintf("unmarshal input json failed: %v", err)), false
|
||||
}
|
||||
if (wbOutput.Code != 0 || wbOutput.Data.To != "openapi") && wbOutput.RawNodes == nil {
|
||||
return nil, output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-cli failed. please check previous log."), false
|
||||
}
|
||||
if wbOutput.RawNodes != nil {
|
||||
wbNodes = struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
}{
|
||||
Nodes: wbOutput.RawNodes,
|
||||
}
|
||||
isRaw = true
|
||||
} else {
|
||||
wbNodes = wbOutput.Data.Result
|
||||
}
|
||||
return wbNodes, nil, isRaw
|
||||
}
|
||||
|
||||
func clearWhiteboardContent(ctx context.Context, runtime *common.RuntimeContext, wbToken string, newNodeIDs []string, dryRun bool) (int, []string, error) {
|
||||
resp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
@@ -201,6 +260,39 @@ func clearWhiteboardContent(ctx context.Context, runtime *common.RuntimeContext,
|
||||
if nodes.Code != 0 {
|
||||
return 0, nil, output.ErrAPI(nodes.Code, "get whiteboard nodes failed", fmt.Sprintf("get whiteboard nodes failed: %s", nodes.Msg))
|
||||
}
|
||||
|
||||
// 收集所有新节点及其 children 的 ID,递归处理
|
||||
protectedIDs := make(map[string]bool)
|
||||
for _, id := range newNodeIDs {
|
||||
protectedIDs[id] = true
|
||||
}
|
||||
// 构建 node map 以便快速查找
|
||||
nodeMap := make(map[string][]string)
|
||||
if nodes.Data.Nodes != nil {
|
||||
for _, node := range nodes.Data.Nodes {
|
||||
nodeMap[node.Id] = node.Children
|
||||
}
|
||||
}
|
||||
// 递归收集所有 children
|
||||
visited := make(map[string]bool)
|
||||
var collectChildren func(id string)
|
||||
collectChildren = func(id string) {
|
||||
if visited[id] {
|
||||
return
|
||||
}
|
||||
visited[id] = true
|
||||
if children, ok := nodeMap[id]; ok {
|
||||
for _, child := range children {
|
||||
protectedIDs[child] = true
|
||||
collectChildren(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range newNodeIDs {
|
||||
collectChildren(id)
|
||||
}
|
||||
|
||||
// 确定要删除的节点
|
||||
nodeIds := make([]string, 0, len(nodes.Data.Nodes))
|
||||
if nodes.Data.Nodes != nil {
|
||||
for _, node := range nodes.Data.Nodes {
|
||||
@@ -209,7 +301,7 @@ func clearWhiteboardContent(ctx context.Context, runtime *common.RuntimeContext,
|
||||
}
|
||||
delIds := make([]string, 0, len(nodeIds))
|
||||
for _, nodeId := range nodeIds {
|
||||
if !slices.Contains(newNodeIDs, nodeId) {
|
||||
if !protectedIDs[nodeId] {
|
||||
delIds = append(delIds, nodeId)
|
||||
}
|
||||
}
|
||||
@@ -218,7 +310,9 @@ func clearWhiteboardContent(ctx context.Context, runtime *common.RuntimeContext,
|
||||
}
|
||||
// 实际删除节点,按每批最多100个进行切分
|
||||
for i := 0; i < len(delIds); i += 100 {
|
||||
time.Sleep(time.Millisecond * 1000) // 画板内删除大量节点时,内部会有大量写操作,需要稍等一下,避免被限流
|
||||
if !skipDeleteNodesBatchSleep {
|
||||
time.Sleep(time.Millisecond * 1000) // 画板内删除大量节点时,内部会有大量写操作,需要稍等一下,避免被限流
|
||||
}
|
||||
end := i + 100
|
||||
if end > len(delIds) {
|
||||
end = len(delIds)
|
||||
@@ -249,3 +343,133 @@ func clearWhiteboardContent(ctx context.Context, runtime *common.RuntimeContext,
|
||||
}
|
||||
return len(delIds), delIds, nil
|
||||
}
|
||||
|
||||
// updateWhiteboardByCode 使用 plantuml/mermaid 代码更新画板
|
||||
func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext, wbToken string, input []byte, format string, overwrite bool, idempotentToken string) error {
|
||||
syntaxType := formatCodeMap[format]
|
||||
reqBody := plantumlCreateReq{
|
||||
PlantUmlCode: string(input),
|
||||
SyntaxType: syntaxType,
|
||||
ParseMode: 1,
|
||||
DiagramType: 0, // 0 表示自动识别
|
||||
}
|
||||
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", url.PathEscape(wbToken)),
|
||||
Body: reqBody,
|
||||
QueryParams: map[string][]string{},
|
||||
}
|
||||
if idempotentToken != "" {
|
||||
req.QueryParams["client_token"] = []string{idempotentToken}
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return output.ErrNetwork(fmt.Sprintf("update whiteboard by code failed: %v", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
|
||||
}
|
||||
|
||||
var createResp plantumlCreateResp
|
||||
err = json.Unmarshal(resp.RawBody, &createResp)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard create response failed: %v", err))
|
||||
}
|
||||
if createResp.Code != 0 {
|
||||
return output.ErrAPI(createResp.Code, "update whiteboard by code failed", fmt.Sprintf("update whiteboard by code failed: %s", createResp.Msg))
|
||||
}
|
||||
|
||||
outData := make(map[string]string)
|
||||
outData["created_node_id"] = createResp.Data.NodeID
|
||||
newNodeIDs := []string{createResp.Data.NodeID}
|
||||
|
||||
if overwrite {
|
||||
numNodes, _, err := clearWhiteboardContent(ctx, runtime, wbToken, newNodeIDs, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData["deleted_nodes_num"] = fmt.Sprintf("%d", numNodes)
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["deleted_nodes_num"] != "" {
|
||||
fmt.Fprintf(w, "%s existing nodes deleted.\n", outData["deleted_nodes_num"])
|
||||
}
|
||||
if outData["created_node_id"] != "" {
|
||||
fmt.Fprintf(w, "New node created.\n")
|
||||
}
|
||||
fmt.Fprintf(w, "Update whiteboard success")
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateWhiteboardByRawNodes 使用原始 Open API 格式数据更新画板
|
||||
func updateWhiteboardByRawNodes(ctx context.Context, runtime *common.RuntimeContext, wbToken string, input []byte, overwrite bool, idempotentToken string) error {
|
||||
nodes, err, isRaw := parseWBcliNodes(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData := make(map[string]string)
|
||||
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)),
|
||||
Body: nodes,
|
||||
QueryParams: map[string][]string{},
|
||||
}
|
||||
if idempotentToken != "" {
|
||||
req.QueryParams["client_token"] = []string{idempotentToken}
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return output.ErrNetwork(fmt.Sprintf("update whiteboard failed: %v", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var detail string
|
||||
if isRaw {
|
||||
detail = fmt.Sprintf("It is not advised to edit openapi format json directly. Please follow instruction in lark-whiteboard skill, " +
|
||||
"using whiteboard-cli to transcript Whiteboard DSL pattern instead.")
|
||||
}
|
||||
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), detail)
|
||||
}
|
||||
|
||||
var createResp createResponse
|
||||
err = json.Unmarshal(resp.RawBody, &createResp)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard create response failed: %v", err))
|
||||
}
|
||||
if createResp.Code != 0 {
|
||||
detail := fmt.Sprintf("update whiteboard failed: %s", createResp.Msg)
|
||||
if isRaw {
|
||||
detail += fmt.Sprintf("\n It is not advised to edit openapi format json directly. Please follow instruction in lark-whiteboard skill, " +
|
||||
"using whiteboard-cli to transcript Whiteboard DSL pattern instead.")
|
||||
}
|
||||
return output.ErrAPI(createResp.Code, "update whiteboard failed", detail)
|
||||
}
|
||||
|
||||
outData["created_node_ids"] = strings.Join(createResp.Data.NodeIDs, ",")
|
||||
|
||||
if overwrite {
|
||||
numNodes, _, err := clearWhiteboardContent(ctx, runtime, wbToken, createResp.Data.NodeIDs, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData["deleted_nodes_num"] = fmt.Sprintf("%d", numNodes)
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["deleted_nodes_num"] != "" {
|
||||
fmt.Fprintf(w, "%s existing nodes deleted.\n", outData["deleted_nodes_num"])
|
||||
}
|
||||
if outData["created_node_ids"] != "" {
|
||||
fmt.Fprintf(w, "%d new nodes created.\n", len(createResp.Data.NodeIDs))
|
||||
}
|
||||
fmt.Fprintf(w, "Update whiteboard success")
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
599
shortcuts/whiteboard/whiteboard_update_test.go
Normal file
599
shortcuts/whiteboard/whiteboard_update_test.go
Normal file
@@ -0,0 +1,599 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWhiteboardUpdate_Validate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
boolFlags map[string]bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid: default format (raw) with token",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"source": "test content",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid: plantuml format",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "plantuml",
|
||||
"source": "test content",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid: mermaid format",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "mermaid",
|
||||
"source": "test content",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid: with idempotent-token",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"idempotent-token": "xxx************xxxx",
|
||||
"source": "test content",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid: bad input_format value",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "invalid",
|
||||
"source": "test content",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid: idempotent-token too short",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"idempotent-token": "short",
|
||||
"source": "test content",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid: with overwrite flag",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"source": "test content",
|
||||
},
|
||||
boolFlags: map[string]bool{
|
||||
"overwrite": true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rt := newTestRuntime(tt.flags, tt.boolFlags)
|
||||
err := wbUpdateValidate(ctx, rt)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("wbUpdateValidate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flagVal string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty defaults to raw",
|
||||
flagVal: "",
|
||||
expected: FormatRaw,
|
||||
},
|
||||
{
|
||||
name: "raw returns raw",
|
||||
flagVal: FormatRaw,
|
||||
expected: FormatRaw,
|
||||
},
|
||||
{
|
||||
name: "plantuml returns plantuml",
|
||||
flagVal: FormatPlantUML,
|
||||
expected: FormatPlantUML,
|
||||
},
|
||||
{
|
||||
name: "mermaid returns mermaid",
|
||||
flagVal: FormatMermaid,
|
||||
expected: FormatMermaid,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"input_format": tt.flagVal}, nil)
|
||||
result := getFormat(rt)
|
||||
if result != tt.expected {
|
||||
t.Errorf("getFormat() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdate_ShortcutRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Verify WhiteboardUpdate is properly configured
|
||||
if WhiteboardUpdate.Command != "+update" {
|
||||
t.Errorf("WhiteboardUpdate.Command = %q, want \"+update\"", WhiteboardUpdate.Command)
|
||||
}
|
||||
if WhiteboardUpdate.Service != "whiteboard" {
|
||||
t.Errorf("WhiteboardUpdate.Service = %q, want \"whiteboard\"", WhiteboardUpdate.Service)
|
||||
}
|
||||
|
||||
// Verify WhiteboardUpdateOld is also properly configured
|
||||
if WhiteboardUpdateOld.Command != "+whiteboard-update" {
|
||||
t.Errorf("WhiteboardUpdateOld.Command = %q, want \"+whiteboard-update\"", WhiteboardUpdateOld.Command)
|
||||
}
|
||||
if WhiteboardUpdateOld.Service != "docs" {
|
||||
t.Errorf("WhiteboardUpdateOld.Service = %q, want \"docs\"", WhiteboardUpdateOld.Service)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+update",
|
||||
"+query",
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(got))
|
||||
for _, shortcut := range got {
|
||||
if seen[shortcut.Command] {
|
||||
t.Fatalf("duplicate shortcut command: %s", shortcut.Command)
|
||||
}
|
||||
seen[shortcut.Command] = true
|
||||
}
|
||||
|
||||
for _, command := range want {
|
||||
if !seen[command] {
|
||||
t.Fatalf("missing shortcut command %q in Shortcuts()", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWBcliNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
wantErr bool
|
||||
wantRaw bool
|
||||
}{
|
||||
{
|
||||
name: "valid with raw nodes",
|
||||
input: []byte(`{"code":0,"data":{"to":"openapi"},"nodes":[{"id":"1"}]}`),
|
||||
wantErr: false,
|
||||
wantRaw: true,
|
||||
},
|
||||
{
|
||||
name: "valid without raw nodes",
|
||||
input: []byte(`{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`),
|
||||
wantErr: false,
|
||||
wantRaw: false,
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
input: []byte(`invalid json`),
|
||||
wantErr: true,
|
||||
wantRaw: false,
|
||||
},
|
||||
{
|
||||
name: "whiteboard-cli failed",
|
||||
input: []byte(`{"code":1,"data":{"to":"other"}}`),
|
||||
wantErr: true,
|
||||
wantRaw: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err, isRaw := parseWBcliNodes(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseWBcliNodes() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !tt.wantErr && isRaw != tt.wantRaw {
|
||||
t.Errorf("parseWBcliNodes() isRaw = %v, want %v", isRaw, tt.wantRaw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWBUpdateDryRun(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
boolFlags map[string]bool
|
||||
}{
|
||||
{
|
||||
name: "dry run raw format",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "raw",
|
||||
"source": `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dry run plantuml format",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "plantuml",
|
||||
"source": "@@startuml\nBob -> Alice : hello\n@@enduml",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dry run mermaid format",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "mermaid",
|
||||
"source": "graph TD\nA-->B",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rt := newTestRuntime(tt.flags, tt.boolFlags)
|
||||
dryRun := wbUpdateDryRun(ctx, rt)
|
||||
if dryRun == nil {
|
||||
t.Fatalf("wbUpdateDryRun() returned nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newUpdateExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, config)
|
||||
return factory, stdout, reg
|
||||
}
|
||||
|
||||
func runUpdateShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
// Temporarily lower risk for testing
|
||||
originalRisk := shortcut.Risk
|
||||
shortcut.Risk = "read"
|
||||
shortcut.AuthTypes = []string{"bot"}
|
||||
|
||||
parent := &cobra.Command{Use: "whiteboard"}
|
||||
shortcut.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
stdout.Reset()
|
||||
err := parent.ExecuteContext(context.Background())
|
||||
|
||||
// Restore original risk
|
||||
shortcut.Risk = originalRisk
|
||||
return err
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawFormat(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock create nodes API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"node1", "node2"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-123", "--input_format", "raw", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_PlantUMLFormat(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock plantuml create API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-plantuml/nodes/plantuml",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node_id": "node1",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
source := `@@startuml
|
||||
Bob -> Alice : hello
|
||||
@@enduml`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-plantuml", "--input_format", "plantuml", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_MermaidFormat(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock plantuml create API response (mermaid uses same endpoint)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-mermaid/nodes/plantuml",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node_id": "node1",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
source := `graph TD
|
||||
A-->B`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-mermaid", "--input_format", "mermaid", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawWithIdempotent(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock create nodes API response with idempotent token
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-idempotent/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"node1"},
|
||||
"client_token": "test-token-1234567890",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-idempotent", "--input_format", "raw", "--idempotent-token", "test-token-1234567890", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawFormatWithRawNodes(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock create nodes API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-nodes/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"node1", "node2"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi"},"nodes":[{"id":"1"}]}`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-raw-nodes", "--input_format", "raw", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock create nodes API response with error
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-api-error/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 10001,
|
||||
"msg": "update failed",
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-raw-api-error", "--input_format", "raw", "--source", source}
|
||||
err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout)
|
||||
// We expect an error here, but don't fail the test because it's testing error path
|
||||
if err == nil {
|
||||
t.Logf("Expected API error, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_PlantUMLAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock plantuml create API response with error
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-plantuml-error/nodes/plantuml",
|
||||
Body: map[string]interface{}{
|
||||
"code": 10001,
|
||||
"msg": "invalid plantuml",
|
||||
},
|
||||
})
|
||||
|
||||
source := `@@startuml
|
||||
invalid
|
||||
@@enduml`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-plantuml-error", "--input_format", "plantuml", "--source", source}
|
||||
err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout)
|
||||
// We expect an error here, but don't fail the test because it's testing error path
|
||||
if err == nil {
|
||||
t.Logf("Expected API error, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_WithOverwrite(t *testing.T) {
|
||||
// Skip sleep for testing
|
||||
origSkip := skipDeleteNodesBatchSleep
|
||||
skipDeleteNodesBatchSleep = true
|
||||
defer func() { skipDeleteNodesBatchSleep = origSkip }()
|
||||
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock 1: Get existing nodes (for clearWhiteboardContent)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{
|
||||
{
|
||||
"id": "old-node-1",
|
||||
"children": []string{},
|
||||
},
|
||||
{
|
||||
"id": "old-node-2",
|
||||
"children": []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 2: Create nodes API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes/plantuml",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node_id": "new-node-123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 3: Delete nodes batch
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-overwrite/nodes/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
source := `graph TD
|
||||
A-->B`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-overwrite", "--input_format", "mermaid", "--overwrite", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateExecute_RawWithOverwrite(t *testing.T) {
|
||||
// Skip sleep for testing
|
||||
origSkip := skipDeleteNodesBatchSleep
|
||||
skipDeleteNodesBatchSleep = true
|
||||
defer func() { skipDeleteNodesBatchSleep = origSkip }()
|
||||
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// Mock 1: Get existing nodes (for clearWhiteboardContent)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{
|
||||
{
|
||||
"id": "old-node-1",
|
||||
"children": []string{"old-child-1"},
|
||||
},
|
||||
{
|
||||
"id": "old-child-1",
|
||||
"children": []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 2: Create nodes API response
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"new-node-1", "new-node-2"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock 3: Delete nodes batch
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-raw-overwrite/nodes/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-raw-overwrite", "--input_format", "raw", "--overwrite", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -106,9 +106,13 @@ Drive Folder (云空间文件夹)
|
||||
- 编辑画板需要使用专门的 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md)
|
||||
|
||||
## 快速决策
|
||||
- 用户说“看一下文档里的图片/附件/素材”“预览素材”,优先用 `lark-cli docs +media-preview`。
|
||||
- 用户明确说“下载素材”,再用 `lark-cli docs +media-download`。
|
||||
- 如果目标明确是画板 / whiteboard / 画板缩略图,只能用 `lark-cli docs +media-download --type whiteboard`,不要用 `+media-preview`。
|
||||
- 用户说“找一个表格”“按名称搜电子表格”“找报表”“最近打开的表格”,先用 `lark-cli docs +search` 做资源发现。
|
||||
- `docs +search` 不是只搜文档 / Wiki;结果里会直接返回 `SHEET` 等云空间对象。
|
||||
- 拿到 spreadsheet URL / token 后,再切到 `lark-sheets` 做对象内部读取、筛选、写入等操作。
|
||||
- 用户说“给文档加评论”“查看评论”“回复评论”“给评论加表情 / reaction”“删除评论表情 / reaction”,**不要留在 `lark-doc`**,直接切到 `lark-drive` 处理。
|
||||
|
||||
## 补充说明
|
||||
`docs +search` 除了搜索文档 / Wiki,也承担“先定位云空间对象,再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
|
||||
@@ -1,8 +1,15 @@
|
||||
|
||||
## 核心概念
|
||||
|
||||
> **导入分流规则:** 如果用户要把本地 Excel / CSV 导入成 Base / 多维表格 / bitable,必须优先使用 `lark-cli drive +import --type bitable`。不要先切到 `lark-base`;`lark-base` 只负责导入完成后的表内操作。
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要把本地 `.xlsx` / `.csv` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 文档类型与 Token
|
||||
|
||||
飞书开放平台中,不同类型的文档有不同的 URL 格式和 Token 处理方式。在进行文档操作(如添加评论、下载文件等)时,必须先获取正确的 `file_token`。
|
||||
@@ -136,6 +143,9 @@ Drive Folder (云空间文件夹)
|
||||
- 使用 `drive file.comments batch_query` 是**已知评论 ID 后**的批量查询,需要传入具体的评论 ID 列表。
|
||||
- 使用 `drive file.comments list` 用于分页获取评论列表,适合统计评论总数、遍历所有评论,或获取"最新/最后 N 条评论"等场景。
|
||||
|
||||
#### Reaction / 表情场景
|
||||
- 遇到评论 / 回复上的 reaction(表情、各表情数量、谁点了什么、添加/删除表情)相关问题时,**先阅读 [lark-drive-reactions.md](../../skills/lark-drive/references/lark-drive-reactions.md) 了解如何使用**。
|
||||
|
||||
### 典型错误与解决方案
|
||||
|
||||
| 错误信息 | 原因 | 解决方案 |
|
||||
|
||||
@@ -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"]
|
||||
@@ -12,269 +12,320 @@ metadata:
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
|
||||
> **执行前必做:** 执行任何 `base` 命令前,必须先阅读对应命令的 reference 文档,再调用命令。
|
||||
> **命名约定:** 仅使用 `lark-cli base +...` 形式的命令。
|
||||
> **分流规则:** 如果用户要“把本地文件导入成 Base / 多维表格 / bitable”,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`。只有导入完成后,才回到 `lark-cli base +...` 做表内操作。
|
||||
> **命名约定:** Base 业务命令仅使用 `lark-cli base +...` 形式;如需先解析 Wiki 链接,可先调用 `lark-cli wiki ...`。
|
||||
> **分流规则:** 如果用户要“把本地文件导入成 Base / 多维表格 / bitable”,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
|
||||
|
||||
## Agent 快速执行顺序
|
||||
## 1. 何时使用本 Skill
|
||||
|
||||
1. **先判断任务类型**
|
||||
- 本地文件导入成 Base / 多维表格 / bitable → 先切 `lark-cli drive +import --type bitable`
|
||||
- 临时统计 / 聚合分析 → `+data-query`
|
||||
- 要把结果长期显示在表里 → formula 字段
|
||||
- 用户明确要 lookup,或确实更适合 `from/select/where/aggregate` → lookup 字段
|
||||
- 明细读取 / 导出 → `+record-list / +record-get`
|
||||
2. **先拿结构,再写命令**
|
||||
- 至少先拿当前表结构:`+field-list` 或 `+table-get`
|
||||
- 跨表场景必须再查**目标表**的结构
|
||||
3. **formula / lookup 有硬门槛**
|
||||
- 先读对应 guide
|
||||
- 读完 guide 后,再创建对应字段
|
||||
4. **写记录前先判断字段可写性**
|
||||
- 只写存储字段
|
||||
- 系统字段 / formula / lookup 默认只读
|
||||
### 1.1 触发条件
|
||||
|
||||
## Agent 禁止行为
|
||||
以下场景应使用本 skill:
|
||||
|
||||
- 不要把 `+record-list` 当聚合分析引擎
|
||||
- 不要没读 guide 就直接创建 formula / lookup 字段
|
||||
- 不要凭自然语言猜表名、字段名、公式表达式里的字段引用
|
||||
- 不要把系统字段、formula 字段、lookup 字段当成 `+record-upsert` 的写入目标
|
||||
- 不要把“本地 Excel / CSV 导入成 Base”误判成 `+base-create`、`+table-create` 或 `+record-upsert`;这一步必须先走 `lark-cli drive +import --type bitable`
|
||||
- 不要在 Base 场景改走 `lark-cli api GET /open-apis/bitable/v1/...`
|
||||
- 不要因为 wiki 解析结果里的 `obj_type=bitable` 就去找 `bitable.*`;在本 CLI 里应继续使用 `lark-cli base +...`
|
||||
- 用户明确要操作飞书多维表格 / Base。
|
||||
- 用户要建表、改表、查表、删表,或管理字段、记录、视图。
|
||||
- 用户要做公式字段、lookup 字段、派生指标、跨表计算。
|
||||
- 用户要做临时统计、聚合分析、比较排序、求最值。
|
||||
- 用户要管理 workflow、dashboard、表单、角色权限。
|
||||
- 用户给出 `/base/{token}` 链接。
|
||||
- 用户给出 `/wiki/{token}` 链接,且最终解析为 `bitable`。
|
||||
- 用户要把旧的 Base 聚合式写法改成当前原子命令写法,例如把旧 `+table / +field / +record / +view / +history / +workspace` 改写成当前命令。
|
||||
|
||||
## Base 基本心智模型
|
||||
以下场景不应使用本 skill:
|
||||
|
||||
1. **Base 字段分三类**
|
||||
- **存储字段**:真实存用户输入的数据,通常适合 `+record-upsert` 写入,例如文本、数字、日期、单选、多选、人员、关联。**附件字段例外**:对 agent 而言,文件上传必须走 `+record-upload-attachment`。
|
||||
- **系统字段**:平台自动维护,只读,典型包括创建时间、最后更新时间、创建人、修改人、自动编号。
|
||||
- **计算字段**:通过表达式或跨表规则推导,只读,典型包括 **公式字段(formula)** 和 **查找引用字段(lookup)**。
|
||||
2. **写记录前先判断字段类别** — 只有存储字段可直接写;公式 / lookup / 创建时间 / 更新时间 / 创建人 / 修改人 / 自动编号都应视为只读输出字段,不能拿来做 `+record-upsert` 入参。
|
||||
3. **Base 不只是存表数据,也能内建计算** — 用户提出“统计、比较、排名、文本拼接、日期差、跨表汇总、状态判断”等需求时,不能默认导出数据后手算;要先判断是否应通过 `+data-query` 或公式字段在 Base 内完成。
|
||||
- 用户只是做认证、初始化配置、切换 `--as user/bot`、处理 scope。此时先读 `../lark-shared/SKILL.md`。
|
||||
- 用户只是泛化地讨论“数据分析 / 字段设计”,但并不在 Base 场景中。不要因为提到“统计 / 公式 / lookup”就误触发。
|
||||
|
||||
## 分析路径决策
|
||||
### 1.2 前置约束
|
||||
|
||||
1. **一次性分析 / 临时查询** → 优先 `+data-query`
|
||||
- 适合:分组统计、SUM / AVG / COUNT / MAX / MIN、条件筛选后聚合。
|
||||
- 特征:要的是“这次算出来的结果”,不是把结果沉淀成表内字段。
|
||||
2. **长期复用的派生指标 / 行级计算结果** → 优先公式字段
|
||||
- 适合:利润率、是否延期、剩余天数、分档标签、跨表汇总后的派生结果。
|
||||
- 特征:要把结果长期显示在 Base 里,跟随记录自动更新。
|
||||
3. **显式要求 Lookup,或确实要按 source/select/where/aggregate 建模** → 用 lookup 字段
|
||||
- 默认仍优先考虑 formula。lookup 只在用户明确要求、或更符合固定查找配置时使用。
|
||||
4. **原始记录读取 / 明细导出** → `+record-list / +record-get`
|
||||
- 不要把 `+record-list` 当分析引擎;它负责取明细,不负责聚合计算。
|
||||
1. 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
|
||||
2. Base 业务命令仅使用 `lark-cli base +...` 形式的 shortcut 命令;如果输入是 Wiki 链接,可先调用 `lark-cli wiki spaces get_node` 解析真实 token。
|
||||
3. 定位到命令后,先读该命令对应的 reference,再执行命令。
|
||||
4. 如果用户要把本地 Excel / CSV 导入成 Base / 多维表格 / bitable,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
|
||||
5. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`。
|
||||
|
||||
## 公式 / Lookup 专项规则
|
||||
## 2. 模块与命令导航
|
||||
|
||||
1. **涉及 formula / lookup 时,先读 guide,再出命令**
|
||||
- formula:[`formula-field-guide.md`](references/formula-field-guide.md)
|
||||
- lookup:[`lookup-field-guide.md`](references/lookup-field-guide.md)
|
||||
2. **guide 先于创建命令**
|
||||
- 没读对应 guide 前,不要直接创建 formula / lookup 字段
|
||||
- 读完 guide 后,再补齐对应 JSON 并创建字段
|
||||
- `type=formula` 必须提供 `expression`
|
||||
- `type=lookup` 必须提供 `from / select / where`,必要时补 `aggregate`
|
||||
3. **公式字段优先于 lookup 字段**
|
||||
- 只要用户的诉求是“计算 / 条件判断 / 文本处理 / 日期差 / 跨表聚合 / 跨表筛选后取值”,默认优先尝试 formula。
|
||||
- 只有用户明确说要 lookup,或配置天然更适合 lookup 四元组时,再走 lookup。
|
||||
4. **表名 / 字段名必须精确匹配**
|
||||
- 公式、lookup、data-query 中出现的表名 / 字段名,必须来自 `+table-list` / `+table-get` / `+field-list` 的真实返回,禁止凭语义猜测改写。
|
||||
5. **先拿结构再写表达式**
|
||||
- 公式或 lookup 一律先获取相关表结构,再生成表达式 / 配置;不要直接凭用户口述拼字段名。
|
||||
本章按“先选模块,再选命令”的方式组织。先判断用户目标属于哪个大模块,再进入对应子模块,按要求阅读 reference 后执行命令。
|
||||
|
||||
## Workflow 专项规则
|
||||
### 2.1 模块地图
|
||||
|
||||
1. **执行任何 workflow 命令前,必须先读两份文档:对应的命令文档 + [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)**
|
||||
- `+workflow-create` → 先读 [lark-base-workflow-create.md](references/lark-base-workflow-create.md) + schema
|
||||
- `+workflow-update` → 先读 [lark-base-workflow-update.md](references/lark-base-workflow-update.md) + schema
|
||||
- `+workflow-list` → 先读 [lark-base-workflow-list.md](references/lark-base-workflow-list.md) + schema
|
||||
- `+workflow-get` → 先读 [lark-base-workflow-get.md](references/lark-base-workflow-get.md) + schema
|
||||
- `+workflow-enable` → 先读 [lark-base-workflow-enable.md](references/lark-base-workflow-enable.md) + schema
|
||||
- `+workflow-disable` → 先读 [lark-base-workflow-disable.md](references/lark-base-workflow-disable.md) + schema
|
||||
- schema 中定义了所有 StepType 枚举、步骤结构、Trigger/Action/Branch/Loop 的 data 格式、值引用语法等
|
||||
- 禁止凭自然语言猜测 `type` 值(如把"新增记录"猜成 `CreateTrigger`),必须从 schema 的 StepType 枚举中复制准确的类型名称
|
||||
| 大模块 | 处理什么问题 | 包含的小模块 / 能力 |
|
||||
|------|-------------|-------------------|
|
||||
| Base 模块 | 管理 Base 本体,或从链接进入 Base 场景 | `base-create / base-get / base-copy`,Base / Wiki 链接解析 |
|
||||
| 表与数据模块 | 管理 Base 内部结构与日常数据操作 | `table / field / record / view` |
|
||||
| 公式 / Lookup 模块 | 处理派生字段、条件判断、跨表计算、固定查找引用 | `formula / lookup` 字段创建与更新 |
|
||||
| 数据分析模块 | 做一次性筛选、分组、聚合分析 | `data-query` |
|
||||
| Workflow 模块 | 管理自动化流程 | `workflow-list / get / create / update / enable / disable` |
|
||||
| Dashboard 模块 | 管理仪表盘和图表组件 | `dashboard-* / dashboard-block-*` |
|
||||
| 表单模块 | 管理表单和表单题目 | `form-* / form-questions-*` |
|
||||
| 权限与角色模块 | 管理高级权限和自定义角色 | `advperm-* / role-*` |
|
||||
|
||||
2. **创建前确认依赖信息**
|
||||
- 先通过 `+table-list` / `+field-list` 获取真实的表名、字段名
|
||||
- 禁止凭自然语言猜测表名/字段名填入 workflow 配置
|
||||
### 2.2 Base 模块
|
||||
|
||||
## Dashboard(仪表盘/数据看板)模块
|
||||
**当用户提到 "仪表盘、dashboard、数据看板、图表、可视化、block、组件、添加组件、创建图表" 等仪表盘相关的关键词时,必须阅读** [lark-base-dashboard.md](references/lark-base-dashboard.md) 这个指引文档,了解仪表盘模块的命令和能力后再进行后续操作。
|
||||
用于管理 Base 本体,或从用户给出的链接进入后续 Base 操作。
|
||||
模块索引:[`references/lark-base-workspace.md`](references/lark-base-workspace.md)
|
||||
|
||||
## 核心规则
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+base-create` | 创建新的 Base | [`lark-base-base-create.md`](references/lark-base-base-create.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference;`--folder-token`、`--time-zone` 都是可选项 |
|
||||
| `+base-get` | 获取 Base 信息 | [`lark-base-base-get.md`](references/lark-base-base-get.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 适合确认 Base 本体信息,不替代表/字段结构读取 |
|
||||
| `+base-copy` | 复制已有 Base | [`lark-base-base-copy.md`](references/lark-base-base-copy.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference;复制成功后应主动返回新 Base 标识信息 |
|
||||
|
||||
1. **只使用原子命令** — 使用 `+table-list / +table-get / +field-create / +record-upsert / +view-set-filter / +record-history-list / +base-get` 这类一命令一动作的写法,不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`
|
||||
2. **写记录前先读字段结构** — 先调用 `+field-list` 获取字段结构,再读 [lark-base-shortcut-record-value.md](references/lark-base-shortcut-record-value.md) 确认各字段类型的写入值格式
|
||||
3. **写字段前先看字段属性规范** — 先读 [lark-base-shortcut-field-properties.md](references/lark-base-shortcut-field-properties.md) 确认 `+field-create/+field-update` 的 JSON 结构
|
||||
4. **筛选查询按视图能力执行** — 先读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md) 和 [lark-base-record-list.md](references/lark-base-record-list.md),通过 `+view-set-filter` + `+record-list` 组合完成筛选读取
|
||||
5. **对记录进行分析(涉及"最高/最低/总计/平均/排名/比较/数量"等分析意图)** — 先读 [lark-base-data-query.md](references/lark-base-data-query.md),通过 `+data-query` 进行数据筛选聚合的服务端计算
|
||||
6. **聚合分析与取数互斥** — 需要分组统计 / SUM / MAX / AVG / COUNT 时,必须使用 `+data-query`(服务端计算),禁止用 `+record-list` 拉全量记录再手动计算;反之,`+data-query` 不返回原始记录,取数场景仍走 `+record-list / +record-get`
|
||||
7. **所有 `+xxx-list` 禁止并发调用** — `+table-list / +field-list / +record-list / +view-list / +record-history-list / +role-list` 只能串行执行
|
||||
8. **批量上限 500 条/次** — 同一表建议串行写入,并在批次间延迟 0.5–1 秒
|
||||
9. **统一参数名** — 一律使用 `--base-token`,不使用旧 `--app-token`
|
||||
10. **遇到“公式 / 查找引用 / 派生指标 / 跨表计算”需求,优先走字段方案判断** — 先判断应建 formula / lookup 字段,还是只做一次性 `+data-query`
|
||||
11. **公式、lookup、系统字段默认视为只读** — 除 `+field-create / +field-update` 维护字段定义外,不要把这些字段作为记录写入目标
|
||||
12. **改名和删除按明确意图执行** — `+view-rename` 在目标视图和新名称都明确时可直接执行;`+record-delete / +field-delete / +table-delete` 在用户已经明确要求删除且目标明确时也可直接执行,不需要再补一次确认,并且执行删除命令时要主动补上 `--yes`;只有目标不明确时才继续追问
|
||||
### 2.3 表与数据模块
|
||||
|
||||
## 问卷 / 表单提示
|
||||
这是最常用的大模块,包含 `table / field / record / view` 四类子模块。
|
||||
补充示例:[`references/examples.md`](references/examples.md),适合需要串联 table / record / view 完整操作链路时再读。
|
||||
|
||||
- **获取问卷列表**:使用 `+form-list`(先拿 `form-id`)
|
||||
- **获取单个问卷**:使用 `+form-get`
|
||||
- **获取表单 / 问卷问题**:使用 `+form-questions-list`
|
||||
- **删除问卷 / 表单问题**:使用 `+form-questions-delete`
|
||||
- **创建 / 更新问题**:使用 `+form-questions-create / +form-questions-update`
|
||||
#### 2.3.1 Table 子模块
|
||||
|
||||
## 意图 → 命令索引
|
||||
子模块索引:[`references/lark-base-table.md`](references/lark-base-table.md)
|
||||
|
||||
| 意图 | 推荐命令 | 备注 |
|
||||
|------|---------|------|
|
||||
| 列表 / 获取数据表 | `lark-cli base +table-list` / `+table-get` | 原子命令 |
|
||||
| 创建 / 更新 / 删除数据表 | `lark-cli base +table-create` / `+table-update` / `+table-delete` | 一命令一动作 |
|
||||
| 列表 / 获取字段 | `lark-cli base +field-list` / `+field-get` | 原子命令 |
|
||||
| 创建 / 更新字段 | `lark-cli base +field-create` / `+field-update` | 使用 `--json` |
|
||||
| 创建 / 更新公式字段 | `lark-cli base +field-create` / `+field-update` | `type=formula`;先读 formula guide,再创建 / 更新 |
|
||||
| 创建 / 更新 lookup 字段 | `lark-cli base +field-create` / `+field-update` | `type=lookup`;先读 lookup guide,再创建 / 更新,默认先判断 formula 是否更合适 |
|
||||
| 列表 / 获取记录 | `lark-cli base +record-list` / `+record-get` | 原子命令,如果需要`聚合计算`,`分组统计` 推荐走 `+data-query` |
|
||||
| 创建 / 更新记录 | `lark-cli base +record-upsert` | `--table-id [--record-id] --json` |
|
||||
| 聚合分析 / 比较排序 / 求最值 / 筛选统计 | `lark-cli base +data-query` | 不要用 `+record-list` 拉全量数据再手动计算,需使用 `+data-query` 走服务端计算 |
|
||||
| 配置 / 查询视图 | `lark-cli base +view-*` | `list/get/create/delete/get-*/set-*/rename` |
|
||||
| 查看记录历史 | `lark-cli base +record-history-list` | 按表和记录查询变更历史 |
|
||||
| 按视图筛选查询 | `lark-cli base +view-set-filter` + `lark-cli base +record-list` | 组合调用 |
|
||||
| 把本地文件导入为 Base / 多维表格 | `lark-cli drive +import --type bitable` | 导入阶段属于 `drive`,不是 `base` |
|
||||
| 创建 / 获取 / 复制 Base | `lark-cli base +base-create` / `+base-get` / `+base-copy` | 原子命令 |
|
||||
| 列表 / 获取工作流 | `lark-cli base +workflow-list` / `+workflow-get` | 原子命令 |
|
||||
| 创建 / 更新工作流 | `lark-cli base +workflow-create` / `+workflow-update` | 使用 `--json`,必须阅读 schema |
|
||||
| 启用 / 停用工作流 | `lark-cli base +workflow-enable` / `+workflow-disable` | 一命令一动作 |
|
||||
| 启用 / 停用高级权限 | `lark-cli base +advperm-enable` / `+advperm-disable` | 启用后才能使用自定义角色;停用会使已有角色失效 |
|
||||
| 列表 / 获取角色 | `lark-cli base +role-list / +role-get` | 查看角色摘要或完整配置 |
|
||||
| 创建 / 更新 / 删除角色 | `lark-cli base +role-create / +role-update / +role-delete` | 管理自定义角色权限 |
|
||||
| 列表 / 获取表单 | `lark-cli base +form-list` / `+form-get` | 原子命令 |
|
||||
| 创建 / 更新 / 删除表单 | `lark-cli base +form-create` / `+form-update` / `+form-delete` | 一命令一动作 |
|
||||
| 列表 / 创建 / 更新 / 删除表单问题 | `lark-cli base +form-questions-list` / `+form-questions-create` / `+form-questions-update` / `+form-questions-delete` | 一命令一动作 |
|
||||
| 创建/管理仪表盘及图表 | `+dashboard-* / +dashboard-block-*` | **必须先读** [lark-base-dashboard.md](references/lark-base-dashboard.md) |
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+table-list / +table-get` | 列出数据表,或获取单个表详情 | [`lark-base-table-list.md`](references/lark-base-table-list.md)、[`lark-base-table-get.md`](references/lark-base-table-get.md) | `+table-list` 只能串行执行;`+table-get` 适合删除/修改前确认目标 |
|
||||
| `+table-create / +table-update / +table-delete` | 创建、更新或删除数据表 | [`lark-base-table-create.md`](references/lark-base-table-create.md)、[`lark-base-table-update.md`](references/lark-base-table-update.md)、[`lark-base-table-delete.md`](references/lark-base-table-delete.md) | 创建适合一次性建表;更新前先确认目标表;删除时用户已明确目标可直接执行并带 `--yes` |
|
||||
|
||||
#### 2.3.2 Field 子模块
|
||||
|
||||
## 操作注意事项
|
||||
普通字段管理走这里;如果字段类型是 `formula` 或 `lookup`,转到下方“公式 / Lookup 模块”。
|
||||
子模块索引:[`references/lark-base-field.md`](references/lark-base-field.md)
|
||||
|
||||
- **Base token 口径统一**:统一使用 `--base-token`
|
||||
- **`+xxx-list` 调用纪律**:`+table-list / +field-list / +record-list / +view-list / +record-history-list / +role-list / +dashboard-list / +dashboard-block-list / +workflow-list` 禁止并发调用;批量执行时只能串行
|
||||
- **`+record-list` 分页规则**:`--limit` 最大 `200`。先拉首批并检查返回 `has_more`;仅当 `has_more=true` 且用户明确需要更多数据(如“全部导出/全量明细/继续下一页”)时再继续翻页。用户只要样例或前 N 条时,不要继续拉全量
|
||||
- **字段可写性先判断**:存储字段才可写;公式 / lookup / 系统字段默认只读,写记录时应跳过
|
||||
- **公式能力要主动想到**:用户说“算一下”“生成标签”“判断是否异常”“跨表汇总”“按日期差预警”时,要先判断是否应该建公式字段,而不是只返回手工分析方案
|
||||
- **lookup 不是默认首选**:lookup 只在用户明确要求或确实更适合固定查找模型时使用;常规计算、跨表聚合和条件判断优先 formula
|
||||
- **附件字段**:如果用户要“上传附件 / 给记录加文件”,只能走 `+record-upload-attachment` 这条链路(读字段 → 读记录 → 上传素材 → 回写记录)
|
||||
- **人员字段 / 用户字段**:调试时注意 `user_id_type` 与执行身份(user / bot)差异
|
||||
- **history 使用方式**:`+record-history-list` 按 `table-id + record-id` 查询记录历史,不支持整表历史扫描
|
||||
- **workspace 状态**:已接入 `+base-create / +base-get / +base-copy`
|
||||
- **`+base-create / +base-copy` 结果返回规范**:创建或复制成功后,回复中必须主动返回新 Base 的标识信息。若返回结果里带可访问链接(如 `base.url`),要一并返回
|
||||
- **`+base-create / +base-copy` 友好性规则**:`--folder-token`、`--time-zone`、复制时的 `--name` 都是可选项。用户没有特别要求时,不要为了这些可选参数额外打断;能直接创建/复制就直接执行
|
||||
- **`+base-create / +base-copy` 权限处理(bot 创建)**:若 Base 由应用身份(bot)创建,创建或复制成功后默认继续使用 bot 身份为当前可用 user(指当前 CLI 中 auth 模块已登录且可用的用户身份)添加 `full_access`(管理员)权限,并在回复中明确授权结果(成功 / 无可用 user / 授权失败及原因)。若授权未完成,要继续给出后续引导(稍后重试授权或继续用 bot);owner 转移必须单独确认,禁止擅自执行
|
||||
- **advperm 使用方式**:`+advperm-enable` 启用高级权限后才能管理角色(`+role-*`);`+advperm-disable` 是高风险操作,停用后已有自定义角色全部失效;操作用户必须为 Base 管理员;先读 [lark-base-advperm-enable.md](references/lark-base-advperm-enable.md) / [lark-base-advperm-disable.md](references/lark-base-advperm-disable.md)
|
||||
- **role 使用方式**:`+role-create` 仅支持 `custom_role`;`+role-update` 采用 Delta Merge(`role_name` 和 `role_type` 必须始终提供);`+role-delete` 不可逆且仅支持自定义角色;角色配置支持 `base_rule_map`(Base 级复制/下载)、`table_rule_map`(表级权限含记录/字段粒度)、`dashboard_rule_map`(仪表盘权限)、`docx_rule_map`(文档权限);写角色前先读 [role-config.md](references/role-config.md)
|
||||
- **表单 form-id**:通过 `+form-list` 获取;`+form-create` 返回的 `id` 即 `form-id`,可用于 `+form-questions-*` 操作
|
||||
- **workflow 使用方式**:在创建或更新 workflow 前,必须仔细阅读 [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md) 了解各触发器和节点组件的结构;同时 `+workflow-list` 返回的不是完整树状结构,若需读取完整结构请使用 `+workflow-get`。
|
||||
- **data-query 使用方式**:使用 `+data-query` 前必须先阅读 [lark-base-data-query.md](references/lark-base-data-query.md) 了解 DSL 结构、支持的字段类型、聚合函数和限制条件;DSL 中的 `field_name` 必须与表字段名精确匹配,构造前先用 `+field-list` 获取真实字段名
|
||||
- **公式 / lookup 使用方式**:构造表达式或 where 条件前,至少先拿当前表结构;跨表时要查找目标表的结构,不允许凭自然语言猜字段名
|
||||
- **视图重命名确认规则**:用户已经明确“把哪个视图改成什么名字”时,`+view-rename` 直接执行即可,不需要再补一句确认
|
||||
- **删除确认规则(记录 / 字段 / 表)**:如果用户已经明确说要删除,并且目标也明确,`+record-delete / +field-delete / +table-delete` 可直接执行,不需要再补一次确认;执行时直接带 `--yes` 通过 CLI 的高风险写入校验。只有目标仍有歧义时,再先用 `+record-get / +field-get / +table-get` 或 list 命令确认
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+field-list / +field-get` | 列出字段结构,或获取单个字段详情 | [`lark-base-field-list.md`](references/lark-base-field-list.md)、[`lark-base-field-get.md`](references/lark-base-field-get.md) | 写记录、写字段、做分析前常先读 `+field-list`;`+field-list` 只能串行执行;`+field-get` 适合删除/更新前确认目标 |
|
||||
| `+field-create / +field-update / +field-delete` | 创建、更新或删除普通字段 | [`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-field-delete.md`](references/lark-base-field-delete.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 写字段前先看字段属性规范;如果类型是 `formula / lookup`,先转去读对应 guide;删除时用户已明确目标可直接执行并带 `--yes` |
|
||||
| `+field-search-options` | 查询字段可选项 | [`lark-base-field-search-options.md`](references/lark-base-field-search-options.md) | 适合单选/多选等选项型字段 |
|
||||
|
||||
## Wiki 链接特殊处理(特别关键!)
|
||||
#### 2.3.3 Record 子模块
|
||||
|
||||
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。**不能直接假设 URL 中的 token 就是 file_token**,必须先查询实际类型和真实 token。
|
||||
子模块索引:[`references/lark-base-record.md`](references/lark-base-record.md)、[`references/lark-base-history.md`](references/lark-base-history.md)
|
||||
|
||||
### 处理流程
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或获取单条记录详情 | [`lark-base-record-search.md`](references/lark-base-record-search.md)、[`lark-base-record-list.md`](references/lark-base-record-list.md)、[`lark-base-record-get.md`](references/lark-base-record-get.md) | 默认优先 `+record-list`;仅当用户提供明确搜索关键词时使用 `+record-search`;取数不用来做聚合分析;`--limit` 最大 `200`;仅在用户明确需要时继续翻页;`+record-list` 只能串行执行 |
|
||||
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-shortcut-record-value.md`](references/lark-base-shortcut-record-value.md) | 写前先 `+field-list`;只写存储字段;批量单次建议不超过 `500` 条;附件不要走这里 |
|
||||
| `+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` 只能串行执行 |
|
||||
|
||||
1. **使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'
|
||||
```
|
||||
#### 2.3.4 View 子模块
|
||||
|
||||
2. **从返回结果中提取关键信息**
|
||||
- `node.obj_type`:文档类型(docx/doc/sheet/bitable/slides/file/mindnote)
|
||||
- `node.obj_token`:**真实的文档 token**(用于后续操作)
|
||||
- `node.title`:文档标题
|
||||
子模块索引:[`references/lark-base-view.md`](references/lark-base-view.md)
|
||||
|
||||
3. **根据 `obj_type` 选择后续命令**
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+view-list / +view-get` | 列出视图,或获取视图详情 | [`lark-base-view-list.md`](references/lark-base-view-list.md)、[`lark-base-view-get.md`](references/lark-base-view-get.md) | `+view-list` 只能串行执行;`+view-get` 适合查看已有视图配置 |
|
||||
| `+view-create / +view-delete / +view-rename` | 创建、删除或重命名视图 | [`lark-base-view-create.md`](references/lark-base-view-create.md)、[`lark-base-view-delete.md`](references/lark-base-view-delete.md)、[`lark-base-view-rename.md`](references/lark-base-view-rename.md) | 创建前先确认表和视图类型;删除前先确认目标;用户已明确新名字时可直接重命名 |
|
||||
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-record-list.md`](references/lark-base-record-list.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
|
||||
| `+view-get-sort / +view-set-sort` | 读取或配置排序 | [`lark-base-view-get-sort.md`](references/lark-base-view-get-sort.md)、[`lark-base-view-set-sort.md`](references/lark-base-view-set-sort.md) | 字段名必须来自真实结构 |
|
||||
| `+view-get-group / +view-set-group` | 读取或配置分组 | [`lark-base-view-get-group.md`](references/lark-base-view-get-group.md)、[`lark-base-view-set-group.md`](references/lark-base-view-set-group.md) | 字段名必须来自真实结构 |
|
||||
| `+view-get-visible-fields / +view-set-visible-fields` | 读取或配置视图可见字段 | [`lark-base-view-get-visible-fields.md`](references/lark-base-view-get-visible-fields.md)、[`lark-base-view-set-visible-fields.md`](references/lark-base-view-set-visible-fields.md) | 用于控制视图中的字段顺序与可见性;字段名必须来自真实结构 |
|
||||
| `+view-get-card / +view-set-card` | 读取或配置卡片视图 | [`lark-base-view-get-card.md`](references/lark-base-view-get-card.md)、[`lark-base-view-set-card.md`](references/lark-base-view-set-card.md) | 适合卡片展示场景 |
|
||||
| `+view-get-timebar / +view-set-timebar` | 读取或配置时间轴视图 | [`lark-base-view-get-timebar.md`](references/lark-base-view-get-timebar.md)、[`lark-base-view-set-timebar.md`](references/lark-base-view-set-timebar.md) | 适合时间线展示场景 |
|
||||
|
||||
| obj_type | 说明 | 后续命令 |
|
||||
|----------|------|-----------|
|
||||
| `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` |
|
||||
| `doc` | 旧版云文档 | `drive file.comments.*` |
|
||||
| `sheet` | 电子表格 | `sheets.*` |
|
||||
| `bitable` | 多维表格 | `lark-cli base +...`(优先);如果 shortcut 不覆盖,再用 `lark-cli base <resource> <method>`;**不要**改走 `lark-cli api /open-apis/bitable/v1/...` |
|
||||
| `slides` | 幻灯片 | `drive.*` |
|
||||
| `file` | 文件 | `drive.*` |
|
||||
| `mindnote` | 思维导图 | `drive.*` |
|
||||
### 2.4 公式 / Lookup 模块
|
||||
|
||||
4. **把 wiki 解析出的 `obj_token` 当成 Base token 使用**
|
||||
- 当 `obj_type=bitable` 时,`node.obj_token` 就是后续 `base` 命令应使用的真实 token。
|
||||
- 也就是说:如果原始输入是 `/wiki/...` 链接,不要把 `wiki_token` 直接塞给 `--base-token`。
|
||||
只要用户诉求涉及派生指标、条件判断、文本处理、日期差、跨表计算、跨表筛选后取值,都要先判断是否进入本模块。
|
||||
|
||||
5. **如果已经报了 token 错,再回退检查 wiki**
|
||||
- 如果命令返回 `param baseToken is invalid`、`base_token invalid`、`not found`,并且用户最初给的是 `/wiki/...` 链接或 `wiki_token`,优先怀疑“把 wiki token 当成了 base token”。
|
||||
- 这时不要改走 `bitable/v1` API;应立即重新执行 `lark-cli wiki spaces get_node`,确认 `obj_type=bitable` 后,改用 `node.obj_token` 重新执行 `lark-cli base +...`。
|
||||
默认优先考虑 `formula`:适合常规计算、条件判断、文本处理、日期差、跨表聚合,以及需要长期显示在表里的派生结果。
|
||||
只有当用户明确要求 `lookup`,或场景天然符合 `from / select / where / aggregate` 这种固定查找建模时,再使用 `lookup`。
|
||||
|
||||
### 查询示例
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+field-create`(`type=formula`) | 创建公式字段 | [`formula-field-guide.md`](references/formula-field-guide.md)、[`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 没读 guide 前不要直接创建 |
|
||||
| `+field-update`(`type=formula`) | 更新公式字段 | [`formula-field-guide.md`](references/formula-field-guide.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 先拿当前表结构 |
|
||||
| `+field-create`(`type=lookup`) | 创建 lookup 字段 | [`lookup-field-guide.md`](references/lookup-field-guide.md)、[`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 没读 guide 前不要直接创建 |
|
||||
| `+field-update`(`type=lookup`) | 更新 lookup 字段 | [`lookup-field-guide.md`](references/lookup-field-guide.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 跨表时还要拿目标表结构 |
|
||||
|
||||
```bash
|
||||
# 查询 wiki 节点
|
||||
lark-cli wiki spaces get_node --params '{"token":"Pgrr***************UnRb"}'
|
||||
```
|
||||
### 2.5 数据分析模块
|
||||
|
||||
返回结果示例:
|
||||
```json
|
||||
{
|
||||
"node": {
|
||||
"obj_type": "docx",
|
||||
"obj_token": "UAJ***************E9nic",
|
||||
"title": "ai friendly 测试 - 1 副本",
|
||||
"node_type": "origin",
|
||||
"space_id": "6946843325487906839"
|
||||
}
|
||||
}
|
||||
```
|
||||
用于一次性分析和临时聚合查询。用户要的是“这次算出来的结果”,而不是把结果沉淀成字段时,优先进入本模块。
|
||||
|
||||
## Base 链接解析规则
|
||||
| 链接类型 | 格式 | 处理方式 |
|
||||
|---------|------|---------|
|
||||
| 直接 Base 链接 | `/base/{token}` | 直接提取作为 `--base-token` |
|
||||
| Wiki 知识库链接 | `/wiki/{token}` | 先调用 `wiki.spaces.get_node`,取 `node.obj_token` |
|
||||
### URL 参数提取
|
||||
```
|
||||
https://{domain}/base/{base-token}?table={table-id}&view={view-id}
|
||||
```
|
||||
- `/base/{token}` → `--base-token`
|
||||
- `?table={id}` → `--table-id`
|
||||
- `?view={id}` → `--view-id`
|
||||
### 禁止事项
|
||||
- **禁止**将完整 URL 直接作为 `--base-token` 参数传入
|
||||
- **禁止**将 wiki_token 直接作为 `--base-token`
|
||||
进入本模块前先确认几件事:
|
||||
|
||||
## 常见错误速查
|
||||
- `+data-query` 只做聚合查询(分组、过滤、排序、聚合计算),不用于列出原始记录或逐条明细。
|
||||
- 调用者必须是目标多维表格的管理员,拥有目标多维表格的 FA(Full Access / 完全访问权限),否则会返回权限错误。
|
||||
- `+data-query` 只支持白名单字段类型;`formula`、`lookup`、附件、系统字段、关联等字段不能用于 `dimensions / measures / filters / sort`。
|
||||
|
||||
| 错误码 | 含义 | 解决方案 |
|
||||
|--------|------|----------|
|
||||
| 1254064 | 日期格式错误 | 用毫秒时间戳,非字符串 / 秒级 |
|
||||
| 1254068 | 超链接格式错误 | 用 `{text, link}` 对象 |
|
||||
| 1254066 | 人员字段错误 | 用 `[{id:"ou_xxx"}]`,并确认 `user_id_type` |
|
||||
| 1254045 | 字段名不存在 | 检查字段名(含空格、大小写) |
|
||||
| 1254015 | 字段值类型不匹配 | 先 `+field-list`,再按类型构造 |
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+data-query` | 做分组统计、SUM / AVG / COUNT / MAX / MIN、条件筛选后的聚合分析 | [`lark-base-data-query.md`](references/lark-base-data-query.md) | 字段名必须精确匹配真实字段名;不要用 `+record-list` / `+record-search` 拉全量再手算;`+data-query` 不返回原始记录;使用前先确认权限和字段类型是否受支持 |
|
||||
|
||||
### 2.6 Workflow 模块
|
||||
|
||||
这是高约束模块。执行任何 workflow 命令前,都必须先读对应命令文档和 schema。
|
||||
模块索引:[`references/lark-base-workflow.md`](references/lark-base-workflow.md)
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+workflow-list / +workflow-get` | 列出 workflow,或获取完整 workflow 结构 | [`lark-base-workflow-list.md`](references/lark-base-workflow-list.md)、[`lark-base-workflow-get.md`](references/lark-base-workflow-get.md)、[`lark-base-workflow-schema.md`](references/lark-base-workflow-schema.md) | `+workflow-list` 只返回摘要且只能串行执行;需要完整结构时用 `+workflow-get` |
|
||||
| `+workflow-create / +workflow-update` | 创建或更新 workflow | [`lark-base-workflow-create.md`](references/lark-base-workflow-create.md)、[`lark-base-workflow-update.md`](references/lark-base-workflow-update.md)、[`lark-base-workflow-schema.md`](references/lark-base-workflow-schema.md) | 先读 schema;禁止凭自然语言猜 `type`;先确认真实表名和字段名 |
|
||||
| `+workflow-enable / +workflow-disable` | 启用或停用 workflow | [`lark-base-workflow-enable.md`](references/lark-base-workflow-enable.md)、[`lark-base-workflow-disable.md`](references/lark-base-workflow-disable.md)、[`lark-base-workflow-schema.md`](references/lark-base-workflow-schema.md) | 启用或停用前先确认目标 workflow;`workflow_id` 与 `table_id` 需按前缀区分 |
|
||||
|
||||
### 2.7 Dashboard 模块
|
||||
|
||||
当用户提到“仪表盘、dashboard、数据看板、图表、可视化、block、组件、添加组件、创建图表”等关键词时,进入本模块,并先阅读 [`lark-base-dashboard.md`](references/lark-base-dashboard.md)。
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+dashboard-list / +dashboard-get` | 列出仪表盘,或获取仪表盘详情 | [`lark-base-dashboard-list.md`](references/lark-base-dashboard-list.md)、[`lark-base-dashboard-get.md`](references/lark-base-dashboard-get.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md) | 进入仪表盘语义后先读 guide;`+dashboard-list` 只能串行执行 |
|
||||
| `+dashboard-create / +dashboard-update / +dashboard-delete` | 创建、更新或删除仪表盘 | [`lark-base-dashboard-create.md`](references/lark-base-dashboard-create.md)、[`lark-base-dashboard-update.md`](references/lark-base-dashboard-update.md)、[`lark-base-dashboard-delete.md`](references/lark-base-dashboard-delete.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md) | 创建前先明确看板目标和展示场景;更新前先读取当前配置;删除前先确认目标 |
|
||||
| `+dashboard-block-list / +dashboard-block-get` | 列出图表组件,或获取单个 block 详情 | [`lark-base-dashboard-block-list.md`](references/lark-base-dashboard-block-list.md)、[`lark-base-dashboard-block-get.md`](references/lark-base-dashboard-block-get.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | `+dashboard-block-list` 只能串行执行;查看配置细节时读 block config 文档 |
|
||||
| `+dashboard-block-create / +dashboard-block-update / +dashboard-block-delete` | 创建、更新或删除图表组件 | [`lark-base-dashboard-block-create.md`](references/lark-base-dashboard-block-create.md)、[`lark-base-dashboard-block-update.md`](references/lark-base-dashboard-block-update.md)、[`lark-base-dashboard-block-delete.md`](references/lark-base-dashboard-block-delete.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | 涉及 `data_config`、图表类型、filter 时要读 block config 文档;删除前先确认目标 |
|
||||
|
||||
### 2.8 表单模块
|
||||
|
||||
用于管理表单本体和表单题目。
|
||||
模块索引:[`references/lark-base-form.md`](references/lark-base-form.md)、[`references/lark-base-form-questions.md`](references/lark-base-form-questions.md)
|
||||
表单问题相关操作依赖 `form-id`;具体获取方式见 `form-list` 和 `form-create` 的 reference。
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+form-list / +form-get` | 列出表单,或获取单个表单 | [`lark-base-form-list.md`](references/lark-base-form-list.md)、[`lark-base-form-get.md`](references/lark-base-form-get.md) | `+form-list` 可用来获取 `form-id`;`+form-get` 适合查看已有表单配置 |
|
||||
| `+form-create / +form-update / +form-delete` | 创建、更新或删除表单 | [`lark-base-form-create.md`](references/lark-base-form-create.md)、[`lark-base-form-update.md`](references/lark-base-form-update.md)、[`lark-base-form-delete.md`](references/lark-base-form-delete.md) | 创建后可继续进入表单问题相关操作;更新或删除前先确认目标表单 |
|
||||
| `+form-questions-list` | 列出表单题目 | [`lark-base-form-questions-list.md`](references/lark-base-form-questions-list.md) | 适合查看已有题目结构 |
|
||||
| `+form-questions-create / +form-questions-update / +form-questions-delete` | 创建、更新或删除题目 | [`lark-base-form-questions-create.md`](references/lark-base-form-questions-create.md)、[`lark-base-form-questions-update.md`](references/lark-base-form-questions-update.md)、[`lark-base-form-questions-delete.md`](references/lark-base-form-questions-delete.md) | 先确认 `form-id`;更新或删除前先确认题目目标 |
|
||||
|
||||
### 2.9 权限与角色模块
|
||||
|
||||
用于启用高级权限,以及管理 Base 自定义角色。
|
||||
涉及 `+advperm-enable / +advperm-disable / +role-*` 时,操作用户必须为 Base 管理员,否则会返回权限错误。
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+advperm-enable / +advperm-disable` | 启用或停用高级权限 | [`lark-base-advperm-enable.md`](references/lark-base-advperm-enable.md)、[`lark-base-advperm-disable.md`](references/lark-base-advperm-disable.md) | 管理角色前必须先启用;停用是高风险操作,会使已有自定义角色失效 |
|
||||
| `+role-list / +role-get` | 列出角色,或获取角色详情 | [`lark-base-role-list.md`](references/lark-base-role-list.md)、[`lark-base-role-get.md`](references/lark-base-role-get.md)、[`role-config.md`](references/role-config.md) | `+role-list` 只能串行执行;`+role-get` 适合查看完整权限配置 |
|
||||
| `+role-create / +role-update / +role-delete` | 创建、更新或删除角色 | [`lark-base-role-create.md`](references/lark-base-role-create.md)、[`lark-base-role-update.md`](references/lark-base-role-update.md)、[`lark-base-role-delete.md`](references/lark-base-role-delete.md)、[`role-config.md`](references/role-config.md) | `+role-create` 仅支持 `custom_role`;`+role-update` 采用 Delta Merge,`role_name` 和 `role_type` 即使不改也必须传当前值;`+role-delete` 不可逆 |
|
||||
|
||||
## 3. 多维表格通用知识
|
||||
|
||||
飞书多维表格英文名是 `Base`,曾用名 `Bitable`;因此旧文档、返回字段、参数名或错误信息里出现 `bitable` 多属历史兼容,不代表应改用另一套命令体系。
|
||||
|
||||
### 3.1 字段分类与可写性
|
||||
|
||||
| 字段类型 | 含义 | 能否直接作为 `+record-upsert / +record-batch-create / +record-batch-update` 写入目标 | 说明 |
|
||||
|----------|------|-----------------------------------------------------------|------|
|
||||
| 存储字段 | 真实存用户输入的数据 | 可以 | 常见如文本、数字、日期、单选、多选、人员、关联 |
|
||||
| 附件字段 | 存储文件附件 | 不应直接按普通字段写 | 上传附件走 `+record-upload-attachment`;下载附件走 `lark-cli docs +media-download` |
|
||||
| 系统字段 | 平台自动维护 | 不可以 | 常见如创建时间、更新时间、创建人、修改人、自动编号 |
|
||||
| `formula` 字段 | 通过表达式计算 | 不可以 | 只读字段 |
|
||||
| `lookup` 字段 | 通过跨表规则查找引用 | 不可以 | 只读字段 |
|
||||
|
||||
### 3.2 任务选路心智模型
|
||||
|
||||
| 用户诉求 | 优先方案 | 不要误走 |
|
||||
|---------|----------|----------|
|
||||
| 一次性分析 / 临时统计 | `+data-query` | 不要用 `+record-list` / `+record-search` 拉全量后手算 |
|
||||
| 要把结果长期显示在表里 | `formula` 字段 | 不要只给一次性手工分析结果 |
|
||||
| 用户明确要求 lookup,或天然是固定查找配置 | `lookup` 字段 | 不要默认先上 lookup;先判断 formula 是否更合适 |
|
||||
| 读取原始记录明细 / 关键词检索 / 导出 | `+record-search / +record-list / +record-get` | 不要拿 `+data-query` 当取数命令 |
|
||||
| 上传附件到记录 | `+record-upload-attachment` | 不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| 下载记录里的附件文件 | `lark-cli docs +media-download --token <file_token> --output <path>` | `file_token` 从 `+record-get` 返回的附件字段里取;用法见 [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) |
|
||||
| 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 |
|
||||
| 本地 Excel / CSV 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create`、`+table-create` 或 `+record-upsert` |
|
||||
|
||||
### 3.3 表名、字段名与表达式引用
|
||||
|
||||
1. 表名、字段名必须精确匹配真实返回,来源应是 `+table-list / +table-get / +field-list`。
|
||||
2. 不要凭自然语言猜名称,不要自行改写用户口述中的表名、字段名。
|
||||
3. `formula / lookup / data-query / workflow` 中出现的名称同样必须精确匹配;表达式引用、where 条件、DSL 字段名、workflow 配置都遵守同一规则。
|
||||
4. 跨表场景必须额外读取目标表结构,不能只看当前表。
|
||||
|
||||
### 3.4 Token 与链接
|
||||
|
||||
这是高优先级章节。只要用户输入里出现链接、token,或报错涉及 `baseToken` / `wiki_token` / `obj_token`,都应优先回到这里检查。
|
||||
|
||||
| 输入类型 | 正确处理方式 | 说明 |
|
||||
|---------|--------------|------|
|
||||
| 直接 Base 链接 `/base/{token}` | 直接提取 token 作为 `--base-token` | 不要把完整 URL 直接作为 `--base-token` |
|
||||
| Wiki 链接 `/wiki/{token}` | 先 `wiki.spaces.get_node`,再取 `node.obj_token` | 不要把 `wiki_token` 直接当 `--base-token` |
|
||||
| URL 中的 `?table={id}` | 先按前缀判断对象类型 | `tbl` 开头表示数据表 `table-id`,可作为 `--table-id`;`blk` 开头表示仪表盘 `dashboard-ID`;`wkf` 开头表示 `workflow-ID`;`ldx` 开头表示内嵌文档,不要一律当成 `--table-id` |
|
||||
| URL 中的 `?view={id}` | 提取为 `--view-id` | 适合直接定位视图 |
|
||||
|
||||
| `lark-cli wiki spaces get_node` 返回的 `obj_type` | 后续路线 | 说明 |
|
||||
|-----------------------------------------------|----------|------|
|
||||
| `bitable` | 优先走 `lark-cli base +...` | 如果 shortcut 不覆盖,再用 `lark-cli base <resource> <method>`;不要改走 `lark-cli api /open-apis/bitable/v1/...` |
|
||||
| `docx` | 转到文档 / Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
| `sheet` | 转到 Sheets 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
| `slides` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
| `mindnote` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
|
||||
### 3.5 执行身份与人员字段
|
||||
|
||||
- 人员字段 / 用户字段:注意 `user_id_type` 与执行身份(user / bot)差异。
|
||||
- bot 身份:bot 看不到用户私有资源;行为以应用身份执行。
|
||||
- user 身份:依赖用户授权和 scope;更适合操作用户资源。
|
||||
|
||||
## 4. 执行规则
|
||||
|
||||
### 4.1 标准执行顺序
|
||||
|
||||
1. 先判断任务属于哪个模块,选对命令族。
|
||||
2. 如果用户给了链接,先解析 token,不要把 wiki token、完整 URL 或其他对象 ID 误当成 `base_token`。
|
||||
3. 先拿结构,再写命令,避免猜表名、字段名、表达式引用。
|
||||
4. 定位到命令后,先读对应 reference,再执行命令。
|
||||
5. 执行命令,并按返回结果判断下一步。
|
||||
6. 回复时返回关键结果和后续可继续操作的信息,方便 agent 链式执行下一步。
|
||||
|
||||
### 4.2 不可违反规则
|
||||
|
||||
1. 先拿结构,再写命令;至少先拿当前表结构,跨表时还要拿目标表结构。
|
||||
2. 不要猜表名、字段名、表达式引用,一律以真实返回为准。
|
||||
3. 只使用原子命令;不要回退到旧的聚合式 `+table / +field / +record / +view / +history / +workspace`。
|
||||
4. 写记录前先读字段结构;先 `+field-list`,再按字段类型构造写入值。
|
||||
5. 写字段前先看字段属性规范;先读 `lark-base-shortcut-field-properties.md`,再构造 `+field-create / +field-update` 的 JSON。
|
||||
6. 只写可写字段;系统字段、附件字段、`formula`、`lookup` 默认不作为普通记录写入目标。
|
||||
7. 聚合分析与取数分流;统计走 `+data-query`,关键词检索走 `+record-search`,明细走 `+record-list / +record-get`。
|
||||
8. 筛选查询按视图能力执行;先用 `+view-set-filter` 配置筛选,再结合 `+record-list` 读取。
|
||||
9. Base 场景不要改走裸 API,不要切去 `lark-cli api /open-apis/bitable/v1/...`。
|
||||
10. 统一使用 `--base-token`,不使用旧 `--app-token`。
|
||||
11. workflow 场景先读 schema,不要凭自然语言猜 `type`。
|
||||
12. dashboard 场景先读 guide;提到图表、看板、block 就先进入 dashboard 模块。
|
||||
13. formula / lookup 场景先读 guide;没读 guide 前不要直接创建或更新。
|
||||
|
||||
### 4.3 并发、分页与批量限制
|
||||
|
||||
- `+table-list / +field-list / +record-list / +view-list / +record-history-list / +role-list / +dashboard-list / +dashboard-block-list / +workflow-list` 禁止并发调用,只能串行执行。
|
||||
- `+record-list` 分页时,`--limit` 最大 `200`;先拉首批并检查 `has_more`,只有用户明确需要更多数据时再继续翻页。
|
||||
- 批量写入时,单批建议不超过 `500` 条。
|
||||
- 连续写入同一表时,建议串行写入,批次间延迟 `0.5–1` 秒。
|
||||
|
||||
### 4.4 确认与回复规则
|
||||
|
||||
- 视图重命名时,用户已明确“把哪个视图改成什么名字”时,`+view-rename` 直接执行即可。
|
||||
- 删除记录 / 字段 / 表时,如果用户已经明确说要删除,且目标明确,`+record-delete / +field-delete / +table-delete` 可直接执行,并带 `--yes`。
|
||||
- 删除目标仍有歧义时,先用 `+record-get / +field-get / +table-get` 或相应 list 命令确认。
|
||||
- `+base-create / +base-copy` 成功后,回复中必须主动返回新 Base 的标识信息;若结果带可访问链接,也应一并返回。
|
||||
- 若 Base 由 bot 身份创建且当前 CLI 存在可用 user 身份,优先继续补授当前 user 为 `full_access`;owner 转移必须单独确认,禁止擅自执行。
|
||||
|
||||
## 5. 常见错误与恢复
|
||||
|
||||
| 错误 / 现象 | 含义 | 恢复动作 |
|
||||
|-------------|------|----------|
|
||||
| `1254064` | 日期格式错误 | 用毫秒时间戳,非字符串 / 秒级 |
|
||||
| `1254068` | 超链接格式错误 | 用 `{text, link}` 对象 |
|
||||
| `1254066` | 人员字段错误 | 用 `[{id:"ou_xxx"}]`,并确认 `user_id_type` |
|
||||
| `1254045` | 字段名不存在 | 检查字段名(含空格、大小写) |
|
||||
| `1254015` | 字段值类型不匹配 | 先 `+field-list`,再按类型构造 |
|
||||
| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki spaces get_node` 取真实 `obj_token`;当 `obj_type=bitable` 时,用 `node.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` |
|
||||
| `not found` 且用户给的是 wiki 链接 | 常见于把 wiki token 当成 base token | 优先回退检查 wiki 解析,而不是改走 `bitable/v1` |
|
||||
| formula / lookup 创建失败 | 指南未读或结构不合法 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 |
|
||||
| 系统字段 / 公式字段写入失败 | 只读字段被当成可写字段 | 改为写存储字段,计算结果交给 formula / lookup / 系统字段自动产出 |
|
||||
| 1254104 | 批量超 500 条 | 分批调用 |
|
||||
| 1254291 | 并发写冲突 | 串行写入 + 批次间延迟 |
|
||||
| `1254104` | 批量超 500 条 | 分批调用 |
|
||||
| `1254291` | 并发写冲突 | 串行写入 + 批次间延迟 |
|
||||
|
||||
## 参考文档
|
||||
## 6. 参考文档
|
||||
|
||||
- [lark-base-shortcut-field-properties.md](references/lark-base-shortcut-field-properties.md) — `+field-create/+field-update` JSON 规范(推荐)
|
||||
- [lark-base-shortcut-field-properties.md](references/lark-base-shortcut-field-properties.md) — `+field-create/+field-update` 调用前必看,各类型 field JSON 规范
|
||||
- [role-config.md](references/role-config.md) — 角色权限配置详解
|
||||
- [lark-base-shortcut-record-value.md](references/lark-base-shortcut-record-value.md) — `+record-upsert` 值格式规范(推荐)
|
||||
- [formula-field-guide.md](references/formula-field-guide.md) — formula 字段写法、函数约束、CurrentValue 规则、跨表计算模式(强烈推荐)
|
||||
- [lark-base-shortcut-record-value.md](references/lark-base-shortcut-record-value.md) — record 写入(`+record-upsert / +record-batch-create / +record-batch-update`)调用前必看,各类型 record JSON 规范
|
||||
- [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) — `+record-batch-create` 用法与 `--json` 结构
|
||||
- [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) — `+record-batch-update` 用法与 `--json` 结构
|
||||
- [formula-field-guide.md](references/formula-field-guide.md) — formula 字段写法、函数约束、CurrentValue 规则、跨表计算模式
|
||||
- [lookup-field-guide.md](references/lookup-field-guide.md) — lookup 字段配置规则、where/aggregate 约束、与 formula 的取舍
|
||||
- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md) — 视图筛选配置
|
||||
- [lark-base-record-list.md](references/lark-base-record-list.md) — 记录列表读取与分页
|
||||
- [lark-base-record-search.md](references/lark-base-record-search.md) — 关键词搜索记录
|
||||
- [lark-base-advperm-enable.md](references/lark-base-advperm-enable.md) — `+advperm-enable` 启用高级权限
|
||||
- [lark-base-advperm-disable.md](references/lark-base-advperm-disable.md) — `+advperm-disable` 停用高级权限
|
||||
- [lark-base-role-list.md](references/lark-base-role-list.md) — `+role-list` 列出角色
|
||||
@@ -283,13 +334,13 @@ https://{domain}/base/{base-token}?table={table-id}&view={view-id}
|
||||
- [lark-base-role-update.md](references/lark-base-role-update.md) — `+role-update` 更新角色
|
||||
- [lark-base-role-delete.md](references/lark-base-role-delete.md) — `+role-delete` 删除角色
|
||||
- [lark-base-dashboard.md](references/lark-base-dashboard.md) — dashboard 模块工作流指引
|
||||
- [dashboard-block-data-config.md](references/dashboard-block-data-config.md) — Block data_config 结构、图表类型、filter 规则
|
||||
- [dashboard-block-data-config.md](references/dashboard-block-data-config.md) — Block `data_config` 结构、图表类型、filter 规则
|
||||
- [lark-base-workflow.md](references/lark-base-workflow.md) — workflow 命令索引
|
||||
- [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md) — `+workflow-create/+workflow-update` JSON body 数据结构详解,包含触发器及各类节点的配置规则(强烈推荐)
|
||||
- [lark-base-data-query.md](references/lark-base-data-query.md) — `+data-query` 聚合分析(DSL 结构、支持字段类型、聚合函数)
|
||||
- [examples.md](references/examples.md) — 完整操作示例(建表、筛选、更新)
|
||||
- [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md) — `+workflow-create/+workflow-update` JSON body 结构详解
|
||||
- [lark-base-data-query.md](references/lark-base-data-query.md) — `+data-query` 聚合分析,含 DSL 结构、支持字段类型、聚合函数
|
||||
- [examples.md](references/examples.md) — 完整操作示例
|
||||
|
||||
## 命令分组
|
||||
## 7. 命令分组
|
||||
|
||||
> **执行前必做:** 从下表定位到命令后,务必先阅读对应命令的 reference 文档,再调用命令。
|
||||
|
||||
@@ -297,7 +348,7 @@ https://{domain}/base/{base-token}?table={table-id}&view={view-id}
|
||||
|----------|------|
|
||||
| [`table commands`](references/lark-base-table.md) | `+table-list / +table-get / +table-create / +table-update / +table-delete` |
|
||||
| [`field commands`](references/lark-base-field.md) | `+field-list / +field-get / +field-create / +field-update / +field-delete / +field-search-options` |
|
||||
| [`record commands`](references/lark-base-record.md) | `+record-list / +record-get / +record-upsert / +record-upload-attachment / +record-delete` |
|
||||
| [`record commands`](references/lark-base-record.md) | `+record-search / +record-list / +record-get / +record-upsert / +record-batch-create / +record-batch-update / +record-upload-attachment / +record-delete` |
|
||||
| [`view commands`](references/lark-base-view.md) | `+view-list / +view-get / +view-create / +view-delete / +view-get-* / +view-set-* / +view-rename` |
|
||||
| [`data-query commands`](references/lark-base-data-query.md) | `+data-query` |
|
||||
| [`history commands`](references/lark-base-history.md) | `+record-history-list` |
|
||||
@@ -307,4 +358,4 @@ https://{domain}/base/{base-token}?table={table-id}&view={view-id}
|
||||
| [`form commands`](references/lark-base-form-create.md) | `+form-list / +form-get / +form-create / +form-update / +form-delete` |
|
||||
| [`form questions commands`](references/lark-base-form-questions-create.md) | `+form-questions-list / +form-questions-create / +form-questions-update / +form-questions-delete` |
|
||||
| [`workflow commands`](references/lark-base-workflow.md) | `+workflow-list / +workflow-get / +workflow-create / +workflow-update / +workflow-enable / +workflow-disable` |
|
||||
| [`dashboard / dashboard-block commands`](references/lark-base-dashboard.md) | `+dashboard-list / +dashboard-get / +dashboard-create / +dashboard-update / +dashboard-delete / +dashboard-block-list / +dashboard-block-get / +dashboard-block-create / +dashboard-block-update / +dashboard-block-delete` |
|
||||
| [`dashboard / dashboard-block commands`](references/lark-base-dashboard.md) | `+dashboard-list / +dashboard-get / +dashboard-create / +dashboard-update / +dashboard-delete / +dashboard-arrange / +dashboard-block-list / +dashboard-block-get / +dashboard-block-create / +dashboard-block-update / +dashboard-block-delete` |
|
||||
|
||||
@@ -18,6 +18,7 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
|
||||
| `wordCloud` | 词云 |
|
||||
| `radar` | 雷达图 |
|
||||
| `statistics` | 指标卡 |
|
||||
| `text` | 文本(支持 Markdown) |
|
||||
|
||||
## 字段类型与操作符速查(AI 决策用)
|
||||
|
||||
@@ -45,6 +46,29 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
|
||||
| `filter.conjunction` | `"and"` / `"or"` | 筛选逻辑 |
|
||||
| `filter.conditions` | `[{ "field_name", "operator", "value" }]` | 筛选条件数组,value 类型因字段类型而异(见下方 filter 格式规则) |
|
||||
|
||||
### text 类型特殊结构
|
||||
|
||||
`text` 类型组件用于展示富文本内容,**不需要数据源配置**(无 `table_name`、`series`、`group_by`、`filter`)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `text` | string | **必填**。支持 Markdown 语法,详见下方说明 |
|
||||
|
||||
**支持的 Markdown 语法:**
|
||||
|
||||
| 语法 | 示例 | 效果 |
|
||||
|------|------|------|
|
||||
| 一级标题 | `# 标题` | 大标题 |
|
||||
| 二级标题 | `## 标题` | 中标题 |
|
||||
| 三级标题 | `### 标题` | 小标题 |
|
||||
| 加粗 | `**文字**` | **文字** |
|
||||
| 斜体 | `*文字*` | *文字* |
|
||||
| 删除线 | `~~文字~~` | ~~文字~~ |
|
||||
| 有序列表 | `1. 项目` | 1. 项目 |
|
||||
| 无序列表 | `- 项目` | - 项目 |
|
||||
|
||||
> **注意**:以上未提及的 Markdown 语法(如链接、图片、代码块、表格等)均不支持。
|
||||
|
||||
## group_by 详细说明
|
||||
|
||||
### mode 枚举
|
||||
@@ -138,8 +162,10 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
|
||||
## 约束与本地校验
|
||||
|
||||
- 必填与互斥
|
||||
- 必填:`table_name`
|
||||
- 互斥:`series` 与 `count_all` 二选一,且至少提供其一
|
||||
- 图表类型必填:`table_name`
|
||||
- text 类型必填:`text`
|
||||
- 互斥:`series` 与 `count_all` 二选一,且至少提供其一(仅图表类型)
|
||||
- text 类型**不支持**:`series`、`count_all`、`group_by`、`filter`
|
||||
- 长度/结构
|
||||
- `group_by` 最多 2 个;每项 `field_name` 必填
|
||||
- `group_by[].sort.type` 取值 `group|value|view`;`order` 取值 `asc|desc`
|
||||
@@ -147,7 +173,8 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
|
||||
- `series[].rollup` 自动转成大写(如 `sum` → `SUM`)
|
||||
- `group_by[].sort.type/order` 自动转成小写
|
||||
- 本地校验(可通过 `--no-validate` 跳过)
|
||||
- `+dashboard-block-create/update` 默认对 `data_config` 做轻量校验;失败会聚合错误并给出修复建议
|
||||
- `+dashboard-block-create` 默认对 `data_config` 做轻量校验;失败会聚合错误并给出修复建议
|
||||
- `+dashboard-block-update` 不做强类型校验,由后端验证具体字段
|
||||
- 仅需传入合法 JSON;CLI 不会擅自改写你的业务含义
|
||||
|
||||
## 可复制模板
|
||||
@@ -287,6 +314,16 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
|
||||
}
|
||||
```
|
||||
|
||||
文本组件(Markdown 富文本):
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "# 🚀 一级标题\n这是一个 **加粗** *斜体* ~~删除线~~ 的示例。\n\n## 📌 二级标题\n1. 有序列表项 1\n2. 有序列表项 2\n\n### 📌 三级标题\n- 无序列表项 1\n- 无序列表项 2"
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:text 类型组件不需要 `table_name`、`series`、`group_by`、`filter` 等数据源相关字段。
|
||||
|
||||
## 常见错误与修复
|
||||
|
||||
- 同时存在 `series` 与 `count_all`
|
||||
|
||||
83
skills/lark-base/references/lark-base-dashboard-arrange.md
Normal file
83
skills/lark-base/references/lark-base-dashboard-arrange.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# base +dashboard-arrange
|
||||
|
||||
> **前置条件:** 先阅读 [lark-base-dashboard.md](lark-base-dashboard.md) 了解整体工作流
|
||||
|
||||
自动重新排列仪表盘组件布局。服务端根据组件数量和类型进行智能布局优化。
|
||||
|
||||
## 使用场景
|
||||
|
||||
| 场景 | 说明 |
|
||||
|------|------|
|
||||
| **从 0 到 1 搭建后** | 使用 `+dashboard-create` 和 `+dashboard-block-create` 创建仪表盘后,默认布局可能不够工整美观,推荐使用本命令做一次整体重排 |
|
||||
| **用户明确要求** | 用户主动要求对已有仪表盘进行布局重排或美化时 |
|
||||
|
||||
> [!CAUTION]
|
||||
> - **不建议**在已有仪表盘上自动调用此命令,除非用户明确要求
|
||||
> - 排列结果是**服务端智能推荐**,不一定完全符合用户预期
|
||||
> - 无法指定具体位置(如"第一排放 A,第二排放 B"),排列逻辑是**自适应**的
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 基础用法
|
||||
lark-cli base +dashboard-arrange \
|
||||
--base-token xxx \
|
||||
--dashboard-id blk_xxx
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--dashboard-id <id>` | 是 | 仪表盘 ID |
|
||||
| `--user-id-type <type>` | 否 | 用户 ID 类型:open_id / union_id / user_id |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 返回示例
|
||||
|
||||
```json
|
||||
{
|
||||
"dashboard_id": "blk_xxx",
|
||||
"name": "数据分析",
|
||||
"blocks": [
|
||||
{
|
||||
"block_id": "chtbxxxx",
|
||||
"block_name": "总销售额",
|
||||
"block_type": "statistics",
|
||||
"layout": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"block_id": "chtbcrxxxx",
|
||||
"block_name": "月度趋势",
|
||||
"block_type": "column",
|
||||
"layout": {
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 6
|
||||
}
|
||||
}
|
||||
],
|
||||
"arranged": true
|
||||
}
|
||||
```
|
||||
|
||||
## 返回重点
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `blocks[].layout` | 重排后的布局信息,包含 x/y/w/h |
|
||||
| `arranged` | 是否重排成功 |
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** — 执行前必须向用户确认。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base-dashboard.md](lark-base-dashboard.md) — dashboard 模块指引
|
||||
@@ -22,6 +22,14 @@ lark-cli base +dashboard-block-create \
|
||||
--type statistics \
|
||||
--data-config '{"table_name":"订单表","count_all":true}'
|
||||
|
||||
# 文本组件示例(Markdown 富文本)
|
||||
lark-cli base +dashboard-block-create \
|
||||
--base-token xxx \
|
||||
--dashboard-id blk_xxx \
|
||||
--name "说明文字" \
|
||||
--type text \
|
||||
--data-config '{"text":"# 标题\n## 副标题\n**加粗** *斜体* ~~删除~~\n1. 列表1\n2. 列表2"}'
|
||||
|
||||
# 复杂配置用文件传入
|
||||
lark-cli base +dashboard-block-create \
|
||||
--base-token xxx \
|
||||
@@ -40,8 +48,8 @@ lark-cli base +dashboard-block-create \
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--dashboard-id <id>` | 是 | 仪表盘 ID(从 `+dashboard-list/get` 获取) |
|
||||
| `--name <name>` | **是** | 组件名称(允许重名) |
|
||||
| `--type <type>` | **是** | 组件类型,见下方枚举值。**不同 type 对应不同的 data_config 结构**,常用:`column`(柱状图)、`line`(折线图)、`pie`(饼图)、`statistics`(指标卡) |
|
||||
| `--data-config <json>` | 否 | 数据配置 JSON,**结构随 type 变化**。**⚠️ 必须阅读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 了解如何构造** |
|
||||
| `--type <type>` | **是** | 组件类型,见下方枚举值。**不同 type 对应不同的 data_config 结构**,常用:`column`(柱状图)、`line`(折线图)、`pie`(饼图)、`statistics`(指标卡)、`text`(文本) |
|
||||
| `--data-config <json>` | 否 | 数据配置 JSON,**结构随 type 变化**。**⚠️ 必须阅读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 了解如何构造**。创建时会做本地校验,更新时由后端校验 |
|
||||
| `--user-id-type <type>` | 否 | 用户 ID 类型,filter 涉及人员字段时使用 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
@@ -61,6 +69,7 @@ lark-cli base +dashboard-block-create \
|
||||
| `wordCloud` | 词云 |
|
||||
| `radar` | 雷达图 |
|
||||
| `statistics` | 指标卡 |
|
||||
| `text` | 文本(支持 Markdown) |
|
||||
|
||||
## 返回示例
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成**
|
||||
| 在仪表盘里添加组件 | `+dashboard-block-create` | 先读 [lark-base-dashboard-block-create.md](lark-base-dashboard-block-create.md),再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) |
|
||||
| 修改组件 | `+dashboard-block-update` | 先读 [lark-base-dashboard-block-update.md](lark-base-dashboard-block-update.md),再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) |
|
||||
| 查看仪表盘有哪些组件 | `+dashboard-get` 或 `+dashboard-block-list` | 本页下方「查看仪表盘」 |
|
||||
| 智能重排组件布局 | `+dashboard-arrange` | [lark-base-dashboard-arrange.md](lark-base-dashboard-arrange.md) |
|
||||
|
||||
## 典型场景工作流
|
||||
|
||||
@@ -58,6 +59,12 @@ lark-cli base +dashboard-block-create \
|
||||
--data-config '{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}],"group_by":[{"field_name":"月份","mode":"integrated"}]}'
|
||||
|
||||
# 继续创建其他组件...
|
||||
|
||||
# 第 5 步:组件创建完成后,使用 arrange 命令智能重排布局(可选但推荐)
|
||||
# 默认布局可能不够美观,arrange 会根据组件数量和类型自动优化布局
|
||||
lark-cli base +dashboard-arrange \
|
||||
--base-token xxx \
|
||||
--dashboard-id blk_xxx
|
||||
```
|
||||
|
||||
### 场景 2:在已有仪表盘上添加新组件
|
||||
@@ -119,7 +126,26 @@ lark-cli base +dashboard-block-update \
|
||||
--data-config '{...}'
|
||||
```
|
||||
|
||||
### 场景 4:读取仪表盘或组件现状
|
||||
### 场景 4:重排仪表盘布局
|
||||
|
||||
当用户明确要求对已有仪表盘进行布局重排或美化时使用。
|
||||
|
||||
> [!CAUTION]
|
||||
> - 排列结果是**服务端智能推荐**,不一定完全符合用户预期
|
||||
> - 无法指定具体位置(如"第一排放 A,第二排放 B"),排列逻辑是**自适应**的
|
||||
> - **不建议**在已有仪表盘上自动调用,除非用户明确要求
|
||||
|
||||
```bash
|
||||
# 第 1 步:列出仪表盘,定位到目标仪表盘
|
||||
lark-cli base +dashboard-list --base-token xxx
|
||||
|
||||
# 第 2 步:执行智能重排
|
||||
lark-cli base +dashboard-arrange \
|
||||
--base-token xxx \
|
||||
--dashboard-id blk_xxx
|
||||
```
|
||||
|
||||
### 场景 5:读取仪表盘或组件现状
|
||||
|
||||
**选择查询方式:**
|
||||
- 想看仪表盘整体结构(含主题、所有组件名称和类型)→ 用 **方式 A**
|
||||
@@ -154,6 +180,7 @@ lark-cli base +dashboard-block-get --base-token xxx --dashboard-id blk_xxx --blo
|
||||
| 类别比较(谁高谁低) | column | 柱状图组件 |
|
||||
| 占比分布(各部分比例) | pie | 饼图组件 |
|
||||
| 单个关键指标 | statistics | 指标卡组件 |
|
||||
| 富文本说明/标题/注释 | text | 文本组件(支持 Markdown) |
|
||||
|
||||
详细组件类型和 data_config 完整规则:[dashboard-block-data-config.md](dashboard-block-data-config.md)
|
||||
|
||||
@@ -205,6 +232,7 @@ A: 在「添加新组件」或「编辑组件」前查看已有组件可以:
|
||||
| `+dashboard-create` | 创建仪表盘 | [lark-base-dashboard-create.md](lark-base-dashboard-create.md) |
|
||||
| `+dashboard-update` | 修改仪表盘 | [lark-base-dashboard-update.md](lark-base-dashboard-update.md) |
|
||||
| `+dashboard-delete` | 删除仪表盘 | [lark-base-dashboard-delete.md](lark-base-dashboard-delete.md) |
|
||||
| `+dashboard-arrange` | 智能重排布局 | [lark-base-dashboard-arrange.md](lark-base-dashboard-arrange.md) |
|
||||
| `+dashboard-block-list` | 列出组件 | [lark-base-dashboard-block-list.md](lark-base-dashboard-block-list.md) |
|
||||
| `+dashboard-block-get` | 获取单个组件详情 | [lark-base-dashboard-block-get.md](lark-base-dashboard-block-get.md) |
|
||||
| `+dashboard-block-create` | 创建组件 | [lark-base-dashboard-block-create.md](lark-base-dashboard-block-create.md) |
|
||||
|
||||
76
skills/lark-base/references/lark-base-record-batch-create.md
Normal file
76
skills/lark-base/references/lark-base-record-batch-create.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# base +record-batch-create
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
批量创建记录。
|
||||
|
||||
## 适用场景(重点)
|
||||
|
||||
- 适合大量创建写入场景,例如导入 CSV / Excel、外部系统一次性写入新数据。
|
||||
- 当输入是长表格或长文本数据时,先按 [lark-base-shortcut-record-value.md](lark-base-shortcut-record-value.md) 做字段映射和类型规范化,再组装 `fields + rows` 调用本命令写入。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +record-batch-create \
|
||||
--base-token XXXXXX \
|
||||
--table-id tblXXX \
|
||||
--json '{"fields":["标题","状态"],"rows":[["任务 A","Open"],["任务 B","Done"]]}'
|
||||
```
|
||||
|
||||
```bash
|
||||
lark-cli base +record-batch-create \
|
||||
--base-token XXXXXX \
|
||||
--table-id tblXXX \
|
||||
--json @batch-create.json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--json <body>` | 是 | 批量创建请求体,必须是 JSON 对象。支持直接传 JSON 字符串,或 `@<file_path>` 从文件读取 |
|
||||
|
||||
## API 入参详情
|
||||
|
||||
**HTTP 方法和路径:**
|
||||
|
||||
```http
|
||||
POST /open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_create
|
||||
```
|
||||
|
||||
## `--json` 结构
|
||||
|
||||
对象形态:`{"fields":[...],"rows":[...]}`。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `fields` | `string[]` | 是 | 字段 ID 或字段名数组 |
|
||||
| `rows` | `any[][]` | 是 | 二维数组,每一行按 `fields` 同序给值;单次最多 200 行 |
|
||||
|
||||
## 返回重点
|
||||
|
||||
`data` 为多行二维数组,与 `+record-list` 返回的多行数据结构一致(按 `fields` 列顺序对齐)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `fields` | `string[]` | 返回的字段名数组 |
|
||||
| `field_id_list` | `string[]` | 返回的字段 ID 数组 |
|
||||
| `record_id_list` | `string[]` | 新创建记录 ID 列表 |
|
||||
| `data` | `any[][]` | 与 `fields` 对齐的多行数据 |
|
||||
| `ignored_fields` | `array` | 可选,表示被忽略的字段信息 |
|
||||
|
||||
## 坑点
|
||||
|
||||
- ⚠️ `--json` 必须是对象。
|
||||
- ⚠️ 写 `rows` 前必须先阅读 [lark-base-shortcut-record-value.md](lark-base-shortcut-record-value.md),按字段类型填值,禁止按自然语言猜测 value 结构。
|
||||
- ⚠️ `fields` 与 `rows` 列顺序必须一一对应。
|
||||
- ⚠️ 空单元格可以显式用 `null` 填充。
|
||||
- ⚠️ 单次最多 200 行,超出需分批写入。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base-record.md](lark-base-record.md) — record 索引页
|
||||
- [lark-base-shortcut-record-value.md](lark-base-shortcut-record-value.md) — 记录值格式规范
|
||||
71
skills/lark-base/references/lark-base-record-batch-update.md
Normal file
71
skills/lark-base/references/lark-base-record-batch-update.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# base +record-batch-update (batch update)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
批量更新记录(将同一份 `patch` 批量应用到一批 `record_id_list`)。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +record-batch-update \
|
||||
--base-token XXXXXX \
|
||||
--table-id tblXXX \
|
||||
--json '{"record_id_list":["recXXX"],"patch":{"field_id_or_name":"value"}}'
|
||||
```
|
||||
|
||||
```bash
|
||||
lark-cli base +record-batch-update \
|
||||
--base-token XXXXXX \
|
||||
--table-id tblXXX \
|
||||
--json @batch-update.json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--json <body>` | 是 | 批量更新请求体,必须是 JSON 对象。支持直接传 JSON 字符串,或 `@<file_path>` 从文件读取 |
|
||||
|
||||
## 生成 `patch` 前必看
|
||||
|
||||
- 先阅读 [lark-base-shortcut-record-value.md](lark-base-shortcut-record-value.md),按字段类型构造 `patch` 的 value,避免类型不匹配。
|
||||
|
||||
## API 入参详情
|
||||
|
||||
**HTTP 方法和路径:**
|
||||
|
||||
```http
|
||||
POST /open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_update
|
||||
```
|
||||
|
||||
## `--json` 结构
|
||||
|
||||
对象形态:`{"record_id_list":[...],"patch":{...}}`。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `record_id_list` | `string[]` | 是 | 要更新的记录 ID 列表(单次最多 200 条) |
|
||||
| `patch` | `object` | 是 | 同一份字段更新对象,会应用到 `record_id_list` 内所有记录 |
|
||||
|
||||
## 返回重点
|
||||
|
||||
返回结构如下(其中 `update` 可与 `+record-list` 的单行字段对象结构对齐):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `record_id_list` | `string[]` | 本次更新到的记录 ID 列表 |
|
||||
| `update` | `object` | 本次应用的字段更新结果;可能为空对象 |
|
||||
| `ignored_fields` | `{id,name,reason}[]` | 可选,被忽略字段信息 |
|
||||
|
||||
## 坑点
|
||||
|
||||
- ⚠️ `--json` 必须是对象。
|
||||
- ⚠️ 该接口是“同值批量更新”:同一请求内所有 `record_id_list` 都会应用同一份 `patch`。
|
||||
- ⚠️ `record_id_list` 最大 200 条,超过会被接口校验拒绝。
|
||||
- ⚠️ 命令不会自动做字段/行映射转换,传什么就发什么。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base-record.md](lark-base-record.md) — record 索引页
|
||||
@@ -11,12 +11,6 @@ lark-cli base +record-get \
|
||||
--base-token app_xxx \
|
||||
--table-id tbl_xxx \
|
||||
--record-id rec_xxx
|
||||
|
||||
lark-cli base +record-get \
|
||||
--base-token app_xxx \
|
||||
--table-id tbl_xxx \
|
||||
--record-id rec_xxx \
|
||||
--fields 项目名称,状态
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -26,7 +20,6 @@ lark-cli base +record-get \
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--record-id <id>` | 是 | 记录 ID |
|
||||
| `--fields <csv_or_json>` | 否 | 字段名 CSV,或 JSON 字符串数组 |
|
||||
|
||||
## API 入参详情
|
||||
|
||||
@@ -38,8 +31,7 @@ GET /open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id
|
||||
|
||||
## 返回重点
|
||||
|
||||
- 返回 `record` 和 `raw`。
|
||||
- `record` 是裁剪后的单条结果;`raw` 保留接口完整响应。
|
||||
- 成功时直接返回接口 `data` 字段内容。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -2,13 +2,21 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
分页列出一张表里的记录;可按视图过滤。
|
||||
分页列出一张表里的记录;可按视图过滤,也可按字段裁剪返回列。
|
||||
|
||||
> 默认优先使用 `+record-list`;仅当用户提供明确搜索关键词时,才使用 [lark-base-record-search.md](lark-base-record-search.md)。
|
||||
|
||||
## 返回关键字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `has_more` | boolean | 是否还有下一页数据;`true` 表示可继续翻页,`false` 表示已到末页 |
|
||||
| `query_context.record_scope` | string | 记录范围:`all_records`(全表)或 `view_filtered_records`(按视图过滤) |
|
||||
| `query_context.field_scope` | string | 字段范围:`selected_fields`(显式传 `--field-id`)/ `view_visible_fields`(未传 `--field-id` 且按视图可见字段)/ `all_fields`(未传 `--field-id` 且无视图限制) |
|
||||
|
||||
## 字段返回优先级
|
||||
|
||||
- `query_context.field_scope` 的优先级为:`selected_fields`(explicit `--field-id`) > `view_visible_fields`(view visible fields) > `all_fields`(table all fields)。
|
||||
|
||||
## 按需翻页规则
|
||||
|
||||
@@ -24,15 +32,17 @@
|
||||
|
||||
```bash
|
||||
lark-cli base +record-list \
|
||||
--base-token app_xxx \
|
||||
--table-id tbl_xxx \
|
||||
--base-token XXXXXX \
|
||||
--table-id tblXXX \
|
||||
--offset 0 \
|
||||
--limit 100
|
||||
|
||||
lark-cli base +record-list \
|
||||
--base-token app_xxx \
|
||||
--table-id tbl_xxx \
|
||||
--view-id viw_xxx \
|
||||
--base-token XXXXXX \
|
||||
--table-id tblXXX \
|
||||
--view-id vewXXX \
|
||||
--field-id fldStatus \
|
||||
--field-id 项目名称 \
|
||||
--offset 0 \
|
||||
--limit 50
|
||||
```
|
||||
@@ -44,6 +54,7 @@ lark-cli base +record-list \
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--view-id <id>` | 否 | 视图 ID;传入后只读该视图结果 |
|
||||
| `--field-id <id_or_name>` | 否 | 字段 ID 或字段名;可重复传入多个 `--field-id` 裁剪返回列 |
|
||||
| `--offset <n>` | 否 | 分页偏移,默认 `0` |
|
||||
| `--limit <n>` | 否 | 分页大小,默认 `100`,范围 `1-200`(最大 `200`,超过会报错) |
|
||||
|
||||
@@ -55,7 +66,7 @@ lark-cli base +record-list \
|
||||
GET /open-apis/base/v3/bases/:base_token/tables/:table_id/records
|
||||
```
|
||||
|
||||
- 查询参数会附带 `view_id / offset / limit`。
|
||||
- 查询参数会附带 `view_id / field_id(repeatable) / offset / limit`。
|
||||
|
||||
|
||||
## 坑点
|
||||
@@ -63,6 +74,7 @@ GET /open-apis/base/v3/bases/:base_token/tables/:table_id/records
|
||||
- ⚠️ `+record-list` 禁止并发调用;批量拉多个视图或多张表时必须串行。
|
||||
- ⚠️ `--limit` 最大 `200`,不要传超过 `200` 的值。
|
||||
- ⚠️ 分页时优先根据返回的 `has_more` 判断是否继续请求,不要盲目预拉全量数据。
|
||||
- ⚠️ `--field-id` 接受字段 ID 或字段名。
|
||||
- ⚠️ 复杂筛选优先落到视图里,再用 `--view-id` 读取。
|
||||
|
||||
## 参考
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user