Compare commits

..

1 Commits

Author SHA1 Message Date
梁硕
c6b57311b2 docs: add v1.0.6 changelog
Change-Id: Ic5a1d128c9ec903c0e1a9a673f7da8340e775dc0
2026-04-08 21:50:08 +08:00
171 changed files with 869 additions and 6606 deletions

View File

@@ -1,26 +0,0 @@
name: License Header
on:
pull_request:
branches: [main]
paths:
- "**/*.go"
- "**/*.js"
- "**/*.py"
- .licenserc.yaml
- .github/workflows/license-header.yml
permissions:
contents: read
pull-requests: write
jobs:
header-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Check license headers
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
with:
config: .licenserc.yaml

1
.gitignore vendored
View File

@@ -31,7 +31,6 @@ tests/mail/reports/
/log/
# Generated / test artifacts
internal/registry/meta_data.json
cmd/api/download.bin

View File

@@ -27,7 +27,6 @@ linters:
- reassign # checks that package variables are not reassigned
- unconvert # removes unnecessary type conversions
- unused # checks for unused constants, variables, functions and types
- depguard # blocks forbidden package imports
- forbidigo # forbids specific function calls
# To enable later after fixing existing issues:
@@ -46,7 +45,6 @@ linters:
linters:
- bodyclose
- gocritic
- depguard
- forbidigo
- path-except: (shortcuts/|internal/)
linters:
@@ -56,56 +54,79 @@ linters:
- forbidigo
settings:
depguard:
rules:
shortcuts-no-vfs:
files:
- "**/shortcuts/**"
deny:
- pkg: "github.com/larksuite/cli/internal/vfs"
desc: >-
shortcuts must not import internal/vfs directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
- pkg: "github.com/larksuite/cli/internal/vfs/localfileio"
desc: >-
shortcuts must not import internal/vfs/localfileio directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── os: already wrapped in internal/vfs ──
- pattern: os\.(Stat|Lstat|Open|OpenFile|Rename|ReadFile|WriteFile|Getwd|UserHomeDir|ReadDir)\b
msg: "use the corresponding vfs.Xxx() from internal/vfs"
- pattern: os\.(Create|CreateTemp|MkdirTemp)\b
# ── Filesystem operations: use internal/vfs instead ──
- pattern: os\.Stat\b
msg: "use vfs.Stat() from internal/vfs"
- pattern: os\.Lstat\b
msg: "use vfs.Lstat() from internal/vfs"
- pattern: os\.Open\b
msg: "use vfs.Open() from internal/vfs"
- pattern: os\.OpenFile\b
msg: "use vfs.OpenFile() from internal/vfs"
- pattern: os\.Create\b
msg: "use vfs.OpenFile() from internal/vfs"
- pattern: os\.CreateTemp\b
msg: >-
internal/: use vfs.CreateTemp() or vfs.OpenFile().
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
- pattern: os\.Mkdir(All)?\b
internal/: use vfs.CreateTemp() from internal/vfs.
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
- pattern: os\.Mkdir\b
msg: "use vfs.MkdirAll() from internal/vfs"
- pattern: os\.MkdirAll\b
msg: "use vfs.MkdirAll() from internal/vfs"
- pattern: os\.Remove\b
msg: >-
internal/: use vfs.Remove() from internal/vfs.
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
- pattern: os\.RemoveAll\b
msg: >-
internal/: add RemoveAll to internal/vfs/fs.go first, then use vfs.RemoveAll().
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
# ── os: not yet in vfs — add to vfs/fs.go first ──
- pattern: os\.(Chdir|Chmod|Chown|Lchown|Chtimes|CopyFS|DirFS|Link|Symlink|Readlink|Truncate|SameFile)\b
msg: "add this function to internal/vfs/fs.go first, then use vfs.Xxx()"
# ── os: IO streams ──
- pattern: os\.Std(in|out|err)\b
msg: "use IOStreams (In/Out/ErrOut) instead of os.Stdin/Stdout/Stderr"
# ── os: process ──
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
- pattern: os\.Rename\b
msg: "use vfs.Rename() from internal/vfs"
- pattern: os\.ReadFile\b
msg: "use vfs.ReadFile() from internal/vfs"
- pattern: os\.WriteFile\b
msg: "use vfs.WriteFile() from internal/vfs"
- pattern: os\.ReadDir\b
msg: "add ReadDir to internal/vfs/fs.go first, then use vfs.ReadDir()"
- pattern: os\.Getwd\b
msg: "use vfs.Getwd() from internal/vfs"
- pattern: os\.Chdir\b
msg: "add Chdir to internal/vfs/fs.go first, then use vfs.Chdir()"
- pattern: os\.UserHomeDir\b
msg: "use vfs.UserHomeDir() from internal/vfs"
- pattern: os\.Chmod\b
msg: "add Chmod to internal/vfs/fs.go first, then use vfs.Chmod()"
- pattern: os\.Chown\b
msg: "add Chown to internal/vfs/fs.go first, then use vfs.Chown()"
- pattern: os\.Lchown\b
msg: "add Lchown to internal/vfs/fs.go first, then use vfs.Lchown()"
- pattern: os\.Link\b
msg: "add Link to internal/vfs/fs.go first, then use vfs.Link()"
- pattern: os\.Symlink\b
msg: "add Symlink to internal/vfs/fs.go first, then use vfs.Symlink()"
- pattern: os\.Readlink\b
msg: "add Readlink to internal/vfs/fs.go first, then use vfs.Readlink()"
- pattern: os\.Truncate\b
msg: "add Truncate to internal/vfs/fs.go first, then use vfs.Truncate()"
- pattern: os\.DirFS\b
msg: "add DirFS to internal/vfs/fs.go first, then use vfs.DirFS()"
- pattern: os\.SameFile\b
msg: "add SameFile to internal/vfs/fs.go first, then use vfs.SameFile()"
# ── IO streams: use IOStreams from cmdutil instead ──
- pattern: os\.Stdin\b
msg: "use IOStreams.In instead of os.Stdin"
- pattern: os\.Stdout\b
msg: "use IOStreams.Out instead of os.Stdout"
- pattern: os\.Stderr\b
msg: "use IOStreams.ErrOut instead of os.Stderr"
# ── Process-level rules ──
- pattern: os\.Exit\b
msg: >-
Do not use os.Exit in shortcuts/. Return an error instead and let
the caller (cmd layer) decide how to terminate.
# ── filepath: functions that access the filesystem ──
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
msg: >-
These filepath functions access the filesystem directly.
internal/: use vfs helpers or localfileio path validation.
shortcuts/: use runtime.ValidatePath() or runtime.FileIO().
analyze-types: true
gocritic:
disabled-checks:

View File

@@ -1,16 +0,0 @@
header:
license:
content: |
Copyright (c) [year] Lark Technologies Pte. Ltd.
SPDX-License-Identifier: MIT
copyright-year: "2026"
paths:
- '**/*.go'
- '**/*.js'
- '**/*.py'
paths-ignore:
- '**/testdata/**'
comment: on-failure

View File

@@ -2,40 +2,6 @@
All notable changes to this project will be documented in this file.
## [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
@@ -256,7 +222,6 @@ 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

View File

@@ -5,6 +5,7 @@ package api
import (
"context"
"encoding/json"
"fmt"
"io"
"regexp"
@@ -43,6 +44,17 @@ type APIOptions struct {
DryRun bool
}
func parseJsonOpt(input, label string) (map[string]interface{}, error) {
if input == "" {
return nil, nil
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(input), &result); err != nil {
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
return result, nil
}
var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`)
func normalisePath(raw string) string {
@@ -76,8 +88,8 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
@@ -106,19 +118,19 @@ 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.
stdin := opts.Factory.IOStreams.In
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
params, err := parseJsonOpt(opts.Params, "--params")
if err != nil {
return client.RawApiRequest{}, err
}
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, err
if params == nil {
params = map[string]interface{}{}
}
var data interface{}
if opts.Data != "" {
data, err = parseJsonOpt(opts.Data, "--data")
if err != nil {
return client.RawApiRequest{}, err
}
}
if opts.PageSize > 0 {
params["page_size"] = opts.PageSize

View File

@@ -199,22 +199,6 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
}
}
func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when both --params and --data use stdin")
}
if !strings.Contains(err.Error(), "cannot both read from stdin") {
t.Errorf("expected stdin conflict error, got: %v", err)
}
}
func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (

View File

@@ -5,6 +5,7 @@ package service
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
@@ -147,10 +148,10 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
}
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
@@ -309,15 +310,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
schemaPath := opts.SchemaPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
// 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
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, 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
var params map[string]interface{}
if opts.Params != "" {
if err := json.Unmarshal([]byte(opts.Params), &params); err != nil {
return client.RawApiRequest{}, output.ErrValidation("--params invalid JSON format")
}
} else {
params = map[string]interface{}{}
}
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
@@ -366,7 +365,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
}
}
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data)
if err != nil {
return client.RawApiRequest{}, err
}

View File

@@ -308,7 +308,7 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "--params invalid format") {
if !strings.Contains(err.Error(), "--params invalid JSON format") {
t.Errorf("unexpected error: %v", err)
}
}
@@ -331,24 +331,6 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
}
}
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when both --params and --data use stdin")
}
if !strings.Contains(err.Error(), "cannot both read from stdin") {
t.Errorf("expected stdin conflict error, got: %v", err)
}
}
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package env
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (

View File

@@ -6,7 +6,6 @@ package fileio
import (
"context"
"io"
"io/fs"
)
// Provider creates FileIO instances.
@@ -47,7 +46,6 @@ type FileIO interface {
type FileInfo interface {
Size() int64
IsDir() bool
Mode() fs.FileMode
}
// File is the interface returned by FileIO.Open.

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (

View File

@@ -5,46 +5,35 @@ package cmdutil
import (
"encoding/json"
"io"
"github.com/larksuite/cli/internal/output"
)
// ParseOptionalBody parses --data JSON for methods that accept a request body.
// Supports stdin (-) and single-quote stripping via ResolveInput.
// Returns (nil, nil) if the method has no body or data is empty.
func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) {
func ParseOptionalBody(httpMethod, data string) (interface{}, error) {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return nil, nil
}
resolved, err := ResolveInput(data, stdin)
if err != nil {
return nil, output.ErrValidation("--data: %s", err)
}
if resolved == "" {
if data == "" {
return nil, nil
}
var body interface{}
if err := json.Unmarshal([]byte(resolved), &body); err != nil {
if err := json.Unmarshal([]byte(data), &body); err != nil {
return nil, output.ErrValidation("--data invalid JSON format")
}
return body, nil
}
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
// Supports stdin (-) and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin)
if err != nil {
return nil, output.ErrValidation("%s: %s", label, err)
}
if resolved == "" {
func ParseJSONMap(input, label string) (map[string]any, error) {
if input == "" {
return map[string]any{}, nil
}
var result map[string]any
if err := json.Unmarshal([]byte(resolved), &result); err != nil {
if err := json.Unmarshal([]byte(input), &result); err != nil {
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
return result, nil

View File

@@ -23,7 +23,7 @@ func TestParseOptionalBody(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseOptionalBody(tt.method, tt.data, nil)
got, err := ParseOptionalBody(tt.method, tt.data)
if (err != nil) != tt.wantErr {
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -53,7 +53,7 @@ func TestParseJSONMap(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, tt.label, nil)
got, err := ParseJSONMap(tt.input, tt.label)
if (err != nil) != tt.wantErr {
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"io"
"strings"
)
// ResolveInput resolves special input conventions for a raw flag value:
// - "-" → read all bytes from stdin
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
//
// This allows callers to bypass shell quoting issues (especially on Windows
// PowerShell) by piping JSON via stdin instead of command-line arguments.
func ResolveInput(raw string, stdin io.Reader) (string, error) {
if raw == "" {
return "", nil
}
// stdin
if raw == "-" {
if stdin == nil {
return "", fmt.Errorf("stdin is not available")
}
data, err := io.ReadAll(stdin)
if err != nil {
return "", fmt.Errorf("failed to read stdin: %w", err)
}
s := strings.TrimSpace(string(data))
if s == "" {
return "", fmt.Errorf("stdin is empty (did you forget to pipe input?)")
}
return s, nil
}
// strip surrounding single quotes (Windows cmd.exe passes them literally)
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
raw = raw[1 : len(raw)-1]
}
return raw, nil
}

View File

@@ -1,189 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"strings"
"testing"
)
func TestResolveInput_Stdin(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"key":"value"}` {
t.Errorf("got %q, want %q", got, `{"key":"value"}`)
}
}
func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"k":"v"}` {
t.Errorf("got %q, want %q", got, `{"k":"v"}`)
}
}
func TestResolveInput_Stdin_Empty(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(""))
if err == nil {
t.Error("expected error for empty stdin")
}
if !strings.Contains(err.Error(), "stdin is empty") {
t.Errorf("expected 'stdin is empty' error, got: %v", err)
}
}
type errorReader struct{}
func (errorReader) Read([]byte) (int, error) { return 0, fmt.Errorf("disk failure") }
func TestResolveInput_Stdin_ReadError(t *testing.T) {
_, err := ResolveInput("-", errorReader{})
if err == nil || !strings.Contains(err.Error(), "failed to read stdin") {
t.Errorf("expected read error, got: %v", err)
}
}
func TestResolveInput_Stdin_WhitespaceOnly(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "))
if err == nil {
t.Error("expected error for whitespace-only stdin")
}
}
func TestResolveInput_Stdin_Nil(t *testing.T) {
_, err := ResolveInput("-", nil)
if err == nil {
t.Error("expected error for nil stdin")
}
}
func TestResolveInput_StripSingleQuotes(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"cmd.exe JSON", `'{"key":"value"}'`, `{"key":"value"}`},
{"cmd.exe empty", `'{}'`, `{}`},
{"no quotes", `{"key":"value"}`, `{"key":"value"}`},
{"just quotes", `''`, ``},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResolveInput(tt.in, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestResolveInput_Empty(t *testing.T) {
got, err := ResolveInput("", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestResolveInput_PlainValue(t *testing.T) {
got, err := ResolveInput(`{"already":"valid"}`, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"already":"valid"}` {
t.Errorf("got %q, want %q", got, `{"already":"valid"}`)
}
}
func TestResolveInput_AtPrefixPassedThrough(t *testing.T) {
// Without @file support, @-prefixed values are passed as-is
got, err := ResolveInput("@something", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "@something" {
t.Errorf("got %q, want %q", got, "@something")
}
}
// Integration: ResolveInput flows through ParseJSONMap correctly.
func TestParseJSONMap_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"message_id":"om_xxx","user_id_type":"open_id"}`)
got, err := ParseJSONMap("-", "--params", stdin)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d keys, want 2", len(got))
}
}
func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got["key"] != "value" {
t.Errorf("got %v, want key=value", got)
}
}
func TestParseOptionalBody_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"text":"hello"}`)
got, err := ParseOptionalBody("POST", "-", stdin)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil {
t.Fatal("expected non-nil body")
}
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", got)
}
if m["text"] != "hello" {
t.Errorf("got %v, want text=hello", m)
}
}
// Simulates exact strings Go receives on different Windows shells.
func TestParseJSONMap_WindowsShellScenarios(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantErr bool
}{
{"bash: normal JSON", `{"a":"1","b":"2"}`, 2, false},
{"cmd.exe: single-quoted", `'{"a":"1","b":"2"}'`, 2, false}, // strip ' fix
{"PS 5.x: mangled", `{a:1,b:2}`, 0, true}, // unrecoverable
{"PS 5.x: empty JSON OK", `{}`, 0, false}, // no inner "
{"PS 7.3+: normal JSON", `{"a":"1"}`, 1, false}, // already fixed
{"PS escaped: correct", `{"a":"1"}`, 1, false}, // after CommandLineToArgvW
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, "--params", nil)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(got) != tt.wantLen {
t.Errorf("got %d keys, want %d", len(got), tt.wantLen)
}
})
}
}

View File

@@ -240,12 +240,6 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
}
}
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, &ConfigError{Code: 2, Type: "config",
Message: "appId and appSecret keychain key are out of sync",
Hint: err.Error()}
}
secret, err := ResolveSecretInput(app.AppSecret, kc)
if err != nil {
// If the error comes from the keychain, it will already be wrapped as an ExitError.

View File

@@ -5,21 +5,9 @@ package core
import (
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/internal/keychain"
)
// stubKeychain is a minimal KeychainAccess that always returns ErrNotFound.
type stubKeychain struct{}
func (stubKeychain) Get(service, account string) (string, error) {
return "", keychain.ErrNotFound
}
func (stubKeychain) Set(service, account, value string) error { return nil }
func (stubKeychain) Remove(service, account string) error { return nil }
func TestAppConfig_LangSerialization(t *testing.T) {
app := AppConfig{
AppId: "cli_test", AppSecret: PlainSecret("secret"),
@@ -85,85 +73,6 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_new_app",
AppSecret: SecretInput{Ref: &SecretRef{
Source: "keychain",
ID: "appsecret:cli_old_app",
}},
Brand: BrandFeishu,
},
},
}
_, err := ResolveConfigFromMulti(raw, nil, "")
if err == nil {
t.Fatal("expected error for mismatched appId and appSecret keychain key")
}
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err)
}
if cfgErr.Hint == "" {
t.Error("expected non-empty hint in ConfigError")
}
}
func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: PlainSecret("my-secret"),
Brand: BrandFeishu,
},
},
}
cfg, err := ResolveConfigFromMulti(raw, nil, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.AppID != "cli_abc" {
t.Errorf("AppID = %q, want %q", cfg.AppID, "cli_abc")
}
}
func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T) {
// Keychain ref matches appId, so validation passes.
// The subsequent ResolveSecretInput will fail (no real keychain),
// but that proves the mismatch check itself passed.
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: SecretInput{Ref: &SecretRef{
Source: "keychain",
ID: "appsecret:cli_abc",
}},
Brand: BrandFeishu,
},
},
}
_, err := ResolveConfigFromMulti(raw, stubKeychain{}, "")
if err == nil {
// stubKeychain returns ErrNotFound, so we expect a keychain error,
// but NOT a mismatch error — that's the point of this test.
t.Fatal("expected error (keychain entry not found), got nil")
}
// The error should come from keychain resolution, NOT from our mismatch check.
var cfgErr *ConfigError
if errors.As(err, &cfgErr) {
if cfgErr.Message == "appId and appSecret keychain key are out of sync" {
t.Fatal("error came from mismatch check, but keys should match")
}
}
}
func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_PROFILE", "missing")

View File

@@ -52,25 +52,6 @@ func ForStorage(appId string, input SecretInput, kc keychain.KeychainAccess) (Se
return SecretInput{Ref: &SecretRef{Source: "keychain", ID: key}}, nil
}
// ValidateSecretKeyMatch checks that the appSecret keychain key references the
// expected appId. This prevents silent mismatches when config.json is edited by
// hand (e.g. appId changed but appSecret.id still points to the old app).
// Only applicable when appSecret is a keychain SecretRef; other forms are skipped.
func ValidateSecretKeyMatch(appId string, secret SecretInput) error {
if secret.Ref == nil || secret.Ref.Source != "keychain" {
return nil
}
expected := secretAccountKey(appId)
if secret.Ref.ID != expected {
return fmt.Errorf(
"appSecret keychain key %q does not match appId %q (expected %q); "+
"please run `lark-cli config init` to reconfigure",
secret.Ref.ID, appId, expected,
)
}
return nil
}
// RemoveSecretStore cleans up keychain entries when an app is removed.
// Errors are intentionally ignored — cleanup is best-effort.
func RemoveSecretStore(input SecretInput, kc keychain.KeychainAccess) {

View File

@@ -1,59 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"strings"
"testing"
)
func TestValidateSecretKeyMatch_KeychainMatches(t *testing.T) {
secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_abc123"}}
if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil {
t.Errorf("expected no error, got: %v", err)
}
}
func TestValidateSecretKeyMatch_KeychainMismatch(t *testing.T) {
secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_old_app"}}
err := ValidateSecretKeyMatch("cli_new_app", secret)
if err == nil {
t.Fatal("expected error for mismatched appId and keychain key")
}
// Verify the error message contains useful context
msg := err.Error()
for _, want := range []string{"cli_old_app", "cli_new_app", "appsecret:cli_new_app", "config init"} {
if !strings.Contains(msg, want) {
t.Errorf("error message missing %q: %s", want, msg)
}
}
}
func TestValidateSecretKeyMatch_PlainSecret_Skipped(t *testing.T) {
secret := PlainSecret("some-secret")
if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil {
t.Errorf("plain secret should be skipped, got: %v", err)
}
}
func TestValidateSecretKeyMatch_FileRef_Skipped(t *testing.T) {
secret := SecretInput{Ref: &SecretRef{Source: "file", ID: "/tmp/secret.txt"}}
if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil {
t.Errorf("file ref should be skipped, got: %v", err)
}
}
func TestValidateSecretKeyMatch_ZeroValue_Skipped(t *testing.T) {
if err := ValidateSecretKeyMatch("cli_abc123", SecretInput{}); err != nil {
t.Errorf("zero SecretInput should be skipped, got: %v", err)
}
}
func TestValidateSecretKeyMatch_EmptyAppId_Mismatch(t *testing.T) {
secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_abc123"}}
err := ValidateSecretKeyMatch("", secret)
if err == nil {
t.Fatal("expected error when appId is empty but keychain key references a real app")
}
}

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential_test
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keychain
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keychain
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build darwin
package keychain

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build linux
package keychain

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vfs
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vfs
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vfs
import (

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vfs
import (

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.7",
"version": "1.0.6",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
/*
* Issue labeler for this repository.
*

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const path = require("path");

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require('fs');
const { execFileSync } = require('child_process');
const path = require('path');

17
scripts/run.js Executable file → Normal file
View File

@@ -1,27 +1,10 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const ext = process.platform === "win32" ? ".exe" : "";
const bin = path.join(__dirname, "..", "bin", "lark-cli" + ext);
if (!fs.existsSync(bin)) {
console.error(
`Error: lark-cli binary not found at ${bin}\n\n` +
`This usually means the postinstall script was skipped.\n` +
`Common causes:\n` +
` - npm is configured with ignore-scripts=true\n` +
` - The postinstall download failed\n\n` +
`To fix, run the install script manually:\n` +
` node "${path.join(__dirname, "install.js")}"\n`
);
process.exit(1);
}
try {
execFileSync(bin, process.argv.slice(2), { stdio: "inherit" });
} catch (e) {

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require('fs');
const path = require('path');

View File

@@ -5,29 +5,19 @@ package base
import (
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)
// parseCtx carries file I/O dependency for JSON/file parsing helpers.
type parseCtx struct {
fio fileio.FileIO
}
func newParseCtx(runtime *common.RuntimeContext) *parseCtx {
return &parseCtx{fio: runtime.FileIO()}
}
func baseTableID(runtime *common.RuntimeContext) string {
return strings.TrimSpace(runtime.Str("table-id"))
}
func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
func loadJSONInput(raw string, flagName string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", common.FlagErrorf("--%s cannot be empty", flagName)
@@ -39,19 +29,11 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
if path == "" {
return "", common.FlagErrorf("--%s file path cannot be empty after @", flagName)
}
if pc.fio == nil {
return "", common.FlagErrorf("--%s @file inputs require a FileIO provider", flagName)
}
f, err := pc.fio.Open(path)
safePath, err := validate.SafeInputPath(path)
if err != nil {
var pathErr *fileio.PathValidationError
if errors.As(err, &pathErr) {
return "", common.FlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
}
return "", common.FlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
return "", common.FlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, err)
}
defer f.Close()
data, err := io.ReadAll(f)
data, err := vfs.ReadFile(safePath)
if err != nil {
return "", common.FlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
}
@@ -104,18 +86,18 @@ func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags
return active[0], nil
}
func parseObjectList(pc *parseCtx, raw string, flagName string) ([]map[string]interface{}, error) {
func parseObjectList(raw string, flagName string) ([]map[string]interface{}, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
var err error
raw, err = loadJSONInput(pc, raw, flagName)
raw, err = loadJSONInput(raw, flagName)
if err != nil {
return nil, err
}
if strings.HasPrefix(raw, "[") {
arr, err := parseJSONArray(pc, raw, flagName)
arr, err := parseJSONArray(raw, flagName)
if err != nil {
return nil, err
}
@@ -129,16 +111,16 @@ func parseObjectList(pc *parseCtx, raw string, flagName string) ([]map[string]in
}
return items, nil
}
obj, err := parseJSONObject(pc, raw, flagName)
obj, err := parseJSONObject(raw, flagName)
if err != nil {
return nil, err
}
return []map[string]interface{}{obj}, nil
}
func parseJSONValue(pc *parseCtx, raw string, flagName string) (interface{}, error) {
func parseJSONValue(raw string, flagName string) (interface{}, error) {
var err error
raw, err = loadJSONInput(pc, raw, flagName)
raw, err = loadJSONInput(raw, flagName)
if err != nil {
return nil, err
}

View File

@@ -70,22 +70,22 @@ func TestBaseAction(t *testing.T) {
}
func TestParseObjectList(t *testing.T) {
items, err := parseObjectList(testPC, "", "view")
items, err := parseObjectList("", "view")
if err != nil || items != nil {
t.Fatalf("items=%v err=%v", items, err)
}
items, err = parseObjectList(testPC, `{"name":"grid"}`, "view")
items, err = parseObjectList(`{"name":"grid"}`, "view")
if err != nil || len(items) != 1 || items[0]["name"] != "grid" {
t.Fatalf("items=%v err=%v", items, err)
}
items, err = parseObjectList(testPC, `[{"name":"grid"}]`, "view")
items, err = parseObjectList(`[{"name":"grid"}]`, "view")
if err != nil || len(items) != 1 || items[0]["name"] != "grid" {
t.Fatalf("items=%v err=%v", items, err)
}
_, err = parseObjectList(testPC, `[1]`, "view")
_, err = parseObjectList(`[1]`, "view")
if err == nil || !strings.Contains(err.Error(), "must be an object") {
t.Fatalf("err=%v", err)
}

View File

@@ -29,7 +29,6 @@ var BaseDashboardBlockCreate = common.Shortcut{
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
if runtime.Bool("no-validate") {
return nil
}
@@ -37,7 +36,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
if strings.TrimSpace(raw) == "" {
return nil // 允许无 data_config 的创建(某些类型可先创建后配置)
}
cfg, err := parseJSONObject(pc, raw, "data-config")
cfg, err := parseJSONObject(raw, "data-config")
if err != nil {
return err
}
@@ -51,7 +50,6 @@ var BaseDashboardBlockCreate = common.Shortcut{
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body := map[string]interface{}{}
if name := runtime.Str("name"); name != "" {
body["name"] = name
@@ -60,7 +58,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
body["type"] = t
}
if raw := runtime.Str("data-config"); raw != "" {
if parsed, err := parseJSONObject(pc, raw, "data-config"); err == nil {
if parsed, err := parseJSONObject(raw, "data-config"); err == nil {
body["data_config"] = parsed
}
}

View File

@@ -29,7 +29,6 @@ var BaseDashboardBlockUpdate = common.Shortcut{
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
if runtime.Bool("no-validate") {
return nil
}
@@ -37,7 +36,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{
if strings.TrimSpace(raw) == "" {
return nil
}
cfg, err := parseJSONObject(pc, raw, "data-config")
cfg, err := parseJSONObject(raw, "data-config")
if err != nil {
return err
}
@@ -50,13 +49,12 @@ var BaseDashboardBlockUpdate = common.Shortcut{
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body := map[string]interface{}{}
if name := runtime.Str("name"); name != "" {
body["name"] = name
}
if raw := runtime.Str("data-config"); raw != "" {
if parsed, err := parseJSONObject(pc, raw, "data-config"); err == nil {
if parsed, err := parseJSONObject(raw, "data-config"); err == nil {
body["data_config"] = parsed
}
}

View File

@@ -95,7 +95,6 @@ func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext)
}
func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body := map[string]interface{}{}
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
body["name"] = name
@@ -104,7 +103,7 @@ func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContex
body["type"] = blockType
}
if raw := runtime.Str("data-config"); raw != "" {
if parsed, err := parseJSONObject(pc, raw, "data-config"); err == nil {
if parsed, err := parseJSONObject(raw, "data-config"); err == nil {
body["data_config"] = parsed
}
}
@@ -120,13 +119,12 @@ func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContex
}
func dryRunDashboardBlockUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body := map[string]interface{}{}
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
body["name"] = name
}
if raw := runtime.Str("data-config"); raw != "" {
if parsed, err := parseJSONObject(pc, raw, "data-config"); err == nil {
if parsed, err := parseJSONObject(raw, "data-config"); err == nil {
body["data_config"] = parsed
}
}
@@ -242,7 +240,6 @@ func executeDashboardBlockGet(runtime *common.RuntimeContext) error {
}
func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
body := map[string]interface{}{}
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
body["name"] = name
@@ -251,7 +248,7 @@ func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
body["type"] = blockType
}
if raw := runtime.Str("data-config"); raw != "" {
parsed, err := parseJSONObject(pc, raw, "data-config")
parsed, err := parseJSONObject(raw, "data-config")
if err != nil {
return err
}
@@ -272,13 +269,12 @@ func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
}
func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
body := map[string]interface{}{}
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
body["name"] = name
}
if raw := runtime.Str("data-config"); raw != "" {
parsed, err := parseJSONObject(pc, raw, "data-config")
parsed, err := parseJSONObject(raw, "data-config")
if err != nil {
return err
}

View File

@@ -22,10 +22,6 @@ 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)
},

View File

@@ -32,8 +32,7 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
}
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
body, _ := parseJSONObject(runtime.Str("json"), "json")
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").
Body(body).
@@ -42,8 +41,7 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo
}
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
body, _ := parseJSONObject(runtime.Str("json"), "json")
return common.NewDryRunAPI().
PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
Body(body).
@@ -80,8 +78,7 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext)
}
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
pc := newParseCtx(runtime)
raw, _ := loadJSONInput(pc, runtime.Str("json"), "json")
raw, _ := loadJSONInput(runtime.Str("json"), "json")
if raw == "" {
return nil, nil
}
@@ -151,8 +148,7 @@ func executeFieldGet(runtime *common.RuntimeContext) error {
}
func executeFieldCreate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
body, err := parseJSONObject(runtime.Str("json"), "json")
if err != nil {
return err
}
@@ -165,10 +161,9 @@ func executeFieldCreate(runtime *common.RuntimeContext) error {
}
func executeFieldUpdate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
baseToken := runtime.Str("base-token")
tableIDValue := baseTableID(runtime)
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
body, err := parseJSONObject(runtime.Str("json"), "json")
if err != nil {
return err
}

View File

@@ -23,10 +23,6 @@ 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)
},

View File

@@ -29,8 +29,8 @@ type fieldTypeSpec struct {
Extra map[string]interface{}
}
func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]interface{}, error) {
resolved, err := loadJSONInput(pc, raw, flagName)
func parseJSONObject(raw string, flagName string) (map[string]interface{}, error) {
resolved, err := loadJSONInput(raw, flagName)
if err != nil {
return nil, err
}
@@ -41,8 +41,8 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
return result, nil
}
func parseJSONArray(pc *parseCtx, raw string, flagName string) ([]interface{}, error) {
resolved, err := loadJSONInput(pc, raw, flagName)
func parseJSONArray(raw string, flagName string) ([]interface{}, error) {
resolved, err := loadJSONInput(raw, flagName)
if err != nil {
return nil, err
}
@@ -53,12 +53,12 @@ func parseJSONArray(pc *parseCtx, raw string, flagName string) ([]interface{}, e
return result, nil
}
func parseStringListFlexible(pc *parseCtx, raw string, flagName string) ([]string, error) {
func parseStringListFlexible(raw string, flagName string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
resolved, err := loadJSONInput(pc, raw, flagName)
resolved, err := loadJSONInput(raw, flagName)
if err != nil {
return nil, err
}
@@ -82,19 +82,8 @@ func parseStringListFlexible(pc *parseCtx, raw string, flagName string) ([]strin
}
func parseStringList(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item != "" {
result = append(result, item)
}
}
return result
items, _ := parseStringListFlexible(raw, "fields")
return items
}
func deepMergeMaps(dst, src map[string]interface{}) map[string]interface{} {

View File

@@ -10,12 +10,8 @@ import (
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
var testPC = &parseCtx{fio: &localfileio.LocalFileIO{}}
func TestParseHelpers(t *testing.T) {
tmpDir := t.TempDir()
cwd, err := os.Getwd()
@@ -34,36 +30,36 @@ func TestParseHelpers(t *testing.T) {
t.Fatalf("write temp file err=%v", err)
}
_ = tmp.Close()
obj, err := parseJSONObject(testPC, `{"name":"demo"}`, "json")
obj, err := parseJSONObject(`{"name":"demo"}`, "json")
if err != nil || obj["name"] != "demo" {
t.Fatalf("obj=%v err=%v", obj, err)
}
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
if _, err := parseJSONObject(`[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
t.Fatalf("err=%v", err)
}
obj, err = parseJSONObject(testPC, "@"+tmp.Name(), "json")
obj, err = parseJSONObject("@"+tmp.Name(), "json")
if err != nil || obj["name"] != "from-file" {
t.Fatalf("file obj=%v err=%v", obj, err)
}
arr, err := parseJSONArray(testPC, `[1,2]`, "items")
arr, err := parseJSONArray(`[1,2]`, "items")
if err != nil || len(arr) != 2 {
t.Fatalf("arr=%v err=%v", arr, err)
}
if _, err := parseJSONArray(testPC, `{"a":1}`, "items"); err == nil || !strings.Contains(err.Error(), "invalid JSON array") {
if _, err := parseJSONArray(`{"a":1}`, "items"); err == nil || !strings.Contains(err.Error(), "invalid JSON array") {
t.Fatalf("err=%v", err)
}
list, err := parseStringListFlexible(testPC, "a, b, ,c", "fields")
list, err := parseStringListFlexible("a, b, ,c", "fields")
if err != nil || !reflect.DeepEqual(list, []string{"a", "b", "c"}) {
t.Fatalf("list=%v err=%v", list, err)
}
list, err = parseStringListFlexible(testPC, `["x","y"]`, "fields")
list, err = parseStringListFlexible(`["x","y"]`, "fields")
if err != nil || !reflect.DeepEqual(list, []string{"x", "y"}) {
t.Fatalf("list=%v err=%v", list, err)
}
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
if _, err := parseStringListFlexible(`[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
t.Fatalf("err=%v", err)
}
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") {
if _, err := parseJSONValue("{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") {
t.Fatalf("err=%v", err)
}
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
@@ -266,10 +262,10 @@ func TestFilterAndSortHelpers(t *testing.T) {
}
func TestJSONInputHelpers(t *testing.T) {
if got, err := loadJSONInput(testPC, `{"name":"demo"}`, "json"); err != nil || got != `{"name":"demo"}` {
if got, err := loadJSONInput(`{"name":"demo"}`, "json"); err != nil || got != `{"name":"demo"}` {
t.Fatalf("got=%q err=%v", got, err)
}
if _, err := loadJSONInput(testPC, "@", "json"); err == nil || !strings.Contains(err.Error(), "file path cannot be empty") {
if _, err := loadJSONInput("@", "json"); err == nil || !strings.Contains(err.Error(), "file path cannot be empty") {
t.Fatalf("err=%v", err)
}
tmp := t.TempDir()
@@ -285,7 +281,7 @@ func TestJSONInputHelpers(t *testing.T) {
if err := os.WriteFile(emptyPath, []byte(" \n"), 0o644); err != nil {
t.Fatalf("write empty file err=%v", err)
}
if _, err := loadJSONInput(testPC, "@"+emptyPath, "json"); err == nil || !strings.Contains(err.Error(), "is empty") {
if _, err := loadJSONInput("@"+emptyPath, "json"); err == nil || !strings.Contains(err.Error(), "is empty") {
t.Fatalf("err=%v", err)
}
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})

View File

@@ -35,8 +35,7 @@ func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.
}
func dryRunRecordUpsert(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
body, _ := parseJSONObject(runtime.Str("json"), "json")
if recordID := runtime.Str("record-id"); recordID != "" {
return common.NewDryRunAPI().
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
@@ -107,8 +106,7 @@ func executeRecordGet(runtime *common.RuntimeContext) error {
}
func executeRecordUpsert(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
body, err := parseJSONObject(runtime.Str("json"), "json")
if err != nil {
return err
}

View File

@@ -14,9 +14,10 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -90,16 +91,15 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
filePath := runtime.Str("file")
fio := runtime.FileIO()
if fio == nil {
return output.ErrValidation("file operations require a FileIO provider")
}
fileInfo, err := fio.Stat(filePath)
safeFilePath, err := validate.SafeInputPath(filePath)
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe file path: %s", err)
}
return output.ErrValidation("file not accessible: %s: %v", filePath, err)
return output.ErrValidation("unsafe file path: %s", err)
}
filePath = safeFilePath
fileInfo, err := vfs.Stat(filePath)
if err != nil {
return output.ErrValidation("file not found: %s", filePath)
}
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileInfo.Size())/1024/1024)
@@ -209,7 +209,7 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i
}
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
f, err := runtime.FileIO().Open(filePath)
f, err := vfs.Open(filePath)
if err != nil {
return nil, output.ErrValidation("cannot open file: %v", err)
}

View File

@@ -22,10 +22,6 @@ var BaseRecordUpsert = common.Shortcut{
recordRefFlag(false),
{Name: "json", Desc: "record JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"Name":"Alice"}'`,
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},

View File

@@ -107,9 +107,8 @@ func executeTableCreate(runtime *common.RuntimeContext) error {
}
result := map[string]interface{}{"table": created}
tableIDValue := tableID(created)
pc := newParseCtx(runtime)
if tableIDValue != "" && runtime.Str("fields") != "" {
fieldItems, err := parseJSONArray(pc, runtime.Str("fields"), "fields")
fieldItems, err := parseJSONArray(runtime.Str("fields"), "fields")
if err != nil {
return err
}
@@ -140,7 +139,7 @@ func executeTableCreate(runtime *common.RuntimeContext) error {
result["fields"] = createdFields
}
if tableIDValue != "" && runtime.Str("view") != "" {
viewItems, err := parseObjectList(pc, runtime.Str("view"), "view")
viewItems, err := parseObjectList(runtime.Str("view"), "view")
if err != nil {
return err
}

View File

@@ -21,10 +21,6 @@ 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)
},

View File

@@ -35,9 +35,8 @@ func dryRunViewGet(_ context.Context, runtime *common.RuntimeContext) *common.Dr
}
func dryRunViewCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
api := dryRunViewBase(runtime)
bodyList, err := parseObjectList(pc, runtime.Str("json"), "json")
bodyList, err := parseObjectList(runtime.Str("json"), "json")
if err != nil || len(bodyList) == 0 {
return api.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/views")
}
@@ -58,16 +57,14 @@ func dryRunViewGetProperty(runtime *common.RuntimeContext, segment string) *comm
}
func dryRunViewSetJSONObject(runtime *common.RuntimeContext, segment string) *common.DryRunAPI {
pc := newParseCtx(runtime)
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
body, _ := parseJSONObject(runtime.Str("json"), "json")
return dryRunViewBase(runtime).
PUT(fmt.Sprintf("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/%s", url.PathEscape(segment))).
Body(body)
}
func dryRunViewSetWrapped(runtime *common.RuntimeContext, segment string, wrapper string) *common.DryRunAPI {
pc := newParseCtx(runtime)
raw, err := parseJSONValue(pc, runtime.Str("json"), "json")
raw, err := parseJSONValue(runtime.Str("json"), "json")
if err != nil {
raw = nil
}
@@ -171,10 +168,9 @@ func executeViewGet(runtime *common.RuntimeContext) error {
}
func executeViewCreate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
baseToken := runtime.Str("base-token")
tableIDValue := baseTableID(runtime)
viewItems, err := parseObjectList(pc, runtime.Str("json"), "json")
viewItems, err := parseObjectList(runtime.Str("json"), "json")
if err != nil {
return err
}
@@ -215,11 +211,10 @@ func executeViewGetProperty(runtime *common.RuntimeContext, segment string, key
}
func executeViewSetJSONObject(runtime *common.RuntimeContext, segment string, key string) 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")
body, err := parseJSONObject(runtime.Str("json"), "json")
if err != nil {
return err
}
@@ -232,11 +227,10 @@ func executeViewSetJSONObject(runtime *common.RuntimeContext, segment string, ke
}
func executeViewSetWrapped(runtime *common.RuntimeContext, segment string, wrapper string, key string) error {
pc := newParseCtx(runtime)
baseToken := runtime.Str("base-token")
tableIDValue := baseTableID(runtime)
viewRef := runtime.Str("view-id")
raw, err := parseJSONValue(pc, runtime.Str("json"), "json")
raw, err := parseJSONValue(runtime.Str("json"), "json")
if err != nil {
return err
}

View File

@@ -22,10 +22,6 @@ 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)
},

View File

@@ -22,10 +22,6 @@ 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)
},

View File

@@ -20,11 +20,7 @@ var BaseViewSetGroup = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(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.",
{Name: "json", Desc: "group JSON object/array", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONValue(runtime)

View File

@@ -22,10 +22,6 @@ var BaseViewSetSort = common.Shortcut{
viewRefFlag(true),
{Name: "json", Desc: "sort JSON object/array", Required: true},
},
Tips: []string{
`Example: --json '[{"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)
},

View File

@@ -22,10 +22,6 @@ 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)
},

View File

@@ -25,21 +25,19 @@ var BaseWorkflowCreate = common.Shortcut{
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
}
pc := newParseCtx(runtime)
raw, err := loadJSONInput(pc, runtime.Str("json"), "json")
raw, err := loadJSONInput(runtime.Str("json"), "json")
if err != nil {
return err
}
if _, err := parseJSONObject(pc, raw, "json"); err != nil {
if _, err := parseJSONObject(raw, "json"); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
var body map[string]interface{}
if raw, err := loadJSONInput(pc, runtime.Str("json"), "json"); err == nil {
body, _ = parseJSONObject(pc, raw, "json")
if raw, err := loadJSONInput(runtime.Str("json"), "json"); err == nil {
body, _ = parseJSONObject(raw, "json")
}
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/workflows").
@@ -47,12 +45,11 @@ var BaseWorkflowCreate = common.Shortcut{
Set("base_token", runtime.Str("base-token"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
raw, err := loadJSONInput(pc, runtime.Str("json"), "json")
raw, err := loadJSONInput(runtime.Str("json"), "json")
if err != nil {
return err
}
body, err := parseJSONObject(pc, raw, "json")
body, err := parseJSONObject(raw, "json")
if err != nil {
return err
}

View File

@@ -29,16 +29,20 @@ var BaseWorkflowUpdate = common.Shortcut{
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
return common.FlagErrorf("--workflow-id must not be blank")
}
pc := newParseCtx(runtime)
if _, err := parseJSONObject(pc, runtime.Str("json"), "json"); err != nil {
raw, err := loadJSONInput(runtime.Str("json"), "json")
if err != nil {
return err
}
if _, err := parseJSONObject(raw, "json"); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
var body map[string]interface{}
body, _ = parseJSONObject(pc, runtime.Str("json"), "json")
if raw, err := loadJSONInput(runtime.Str("json"), "json"); err == nil {
body, _ = parseJSONObject(raw, "json")
}
return common.NewDryRunAPI().
PUT("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id").
Body(body).
@@ -46,8 +50,11 @@ var BaseWorkflowUpdate = common.Shortcut{
Set("workflow_id", runtime.Str("workflow-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
raw, err := loadJSONInput(runtime.Str("json"), "json")
if err != nil {
return err
}
body, err := parseJSONObject(raw, "json")
if err != nil {
return err
}

View File

@@ -5,6 +5,8 @@ package common
import (
"fmt"
"os"
"path/filepath"
"strconv"
"testing"
"time"
@@ -55,3 +57,32 @@ func TestParseTimeEndHint(t *testing.T) {
t.Errorf("ParseTime(2026-03-15, end) = %v, want 23:59:59", parsed)
}
}
func TestEnsureWritableFile(t *testing.T) {
t.Run("allows missing target", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing.txt")
if err := EnsureWritableFile(path, false); err != nil {
t.Fatalf("EnsureWritableFile() unexpected error: %v", err)
}
})
t.Run("rejects existing target without overwrite", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "exists.txt")
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
if err := EnsureWritableFile(path, false); err == nil {
t.Fatalf("expected overwrite protection error, got nil")
}
})
t.Run("allows existing target with overwrite", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "exists.txt")
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
if err := EnsureWritableFile(path, true); err != nil {
t.Fatalf("EnsureWritableFile() unexpected error: %v", err)
}
})
}

View File

@@ -11,6 +11,8 @@ import (
"io"
"net/http"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
@@ -49,9 +51,13 @@ type DriveMediaMultipartUploadConfig struct {
}
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
f, err := runtime.FileIO().Open(cfg.FilePath)
safeFilePath, err := validate.SafeInputPath(cfg.FilePath)
if err != nil {
return "", WrapInputStatError(err)
return "", output.ErrValidation("invalid file path: %s", err)
}
f, err := vfs.Open(safeFilePath)
if err != nil {
return "", output.ErrValidation("cannot read file: %s", err)
}
defer f.Close()
@@ -167,9 +173,13 @@ func ExtractDriveMediaUploadFileToken(data map[string]interface{}, action string
}
func uploadDriveMediaMultipartParts(runtime *RuntimeContext, filePath string, fileSize int64, session DriveMediaMultipartUploadSession) error {
f, err := runtime.FileIO().Open(filePath)
safeFilePath, err := validate.SafeInputPath(filePath)
if err != nil {
return WrapInputStatError(err)
return output.ErrValidation("invalid file path: %s", err)
}
f, err := vfs.Open(safeFilePath)
if err != nil {
return output.ErrValidation("cannot read file: %s", err)
}
defer f.Close()

View File

@@ -5,9 +5,14 @@ package common
import (
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/textproto"
"os"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs"
)
// MultipartWriter wraps multipart.Writer for file uploads.
@@ -32,3 +37,16 @@ func (mw *MultipartWriter) CreateFormFile(fieldname, filename string) (io.Writer
func ParseJSON(data []byte, v interface{}) error {
return json.Unmarshal(data, v)
}
// EnsureWritableFile refuses to overwrite an existing file unless overwrite is true.
func EnsureWritableFile(path string, overwrite bool) error {
if overwrite {
return nil
}
if _, err := vfs.Stat(path); err == nil {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", path)
} else if !errors.Is(err, os.ErrNotExist) {
return output.Errorf(output.ExitInternal, "io", "cannot access output path %s: %v", path, err)
}
return nil
}

View File

@@ -1,137 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/validate"
)
const (
PermissionGrantGranted = "granted"
PermissionGrantSkipped = "skipped"
PermissionGrantFailed = "failed"
permissionGrantPerm = "full_access"
permissionGrantPermHint = "可管理权限"
)
// AutoGrantCurrentUserDrivePermission grants full_access on a newly created
// Drive resource to the current CLI user when the shortcut runs as bot.
//
// Callers should attach the returned result only when it is non-nil.
func AutoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourceType string) map[string]interface{} {
if runtime == nil || !runtime.IsBot() {
return nil
}
token = strings.TrimSpace(token)
resourceType = strings.TrimSpace(resourceType)
if token == "" || resourceType == "" {
return buildPermissionGrantResult(
PermissionGrantSkipped,
"",
fmt.Sprintf("The operation did not return a permission target (missing token/type), so current user %s was not granted. You can retry later or continue using bot identity.", permissionGrantPermMessage()),
)
}
return autoGrantCurrentUserDrivePermission(runtime, token, resourceType)
}
func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourceType string) map[string]interface{} {
userOpenID := strings.TrimSpace(runtime.UserOpenId())
if userOpenID == "" {
return buildPermissionGrantResult(
PermissionGrantSkipped,
"",
fmt.Sprintf("Resource was created with bot identity, but no current CLI user open_id is configured, so current user %s was not granted. You can retry later or continue using bot identity.", permissionGrantPermMessage()),
)
}
body := map[string]interface{}{
"member_type": "openid",
"member_id": userOpenID,
"perm": permissionGrantPerm,
"type": "user",
}
if permType := permissionGrantPermType(resourceType); permType != "" {
body["perm_type"] = permType
}
_, err := runtime.CallAPI(
"POST",
fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(token)),
map[string]interface{}{
"type": resourceType,
"need_notification": false,
},
body,
)
if err != nil {
return buildPermissionGrantResult(
PermissionGrantFailed,
userOpenID,
fmt.Sprintf("Resource was created, but granting current user %s failed: %s. You can retry later or continue using bot identity.", permissionGrantPermMessage(), compactPermissionGrantError(err)),
)
}
return buildPermissionGrantResult(
PermissionGrantGranted,
userOpenID,
fmt.Sprintf("Granted the current CLI user %s on the new %s.", permissionGrantPermMessage(), permissionTargetLabel(resourceType)),
)
}
func buildPermissionGrantResult(status, userOpenID, message string) map[string]interface{} {
result := map[string]interface{}{
"status": status,
"perm": permissionGrantPerm,
"message": message,
}
if userOpenID != "" {
result["user_open_id"] = userOpenID
result["member_type"] = "openid"
}
return result
}
func permissionGrantPermMessage() string {
return permissionGrantPerm + " (" + permissionGrantPermHint + ")"
}
func permissionGrantPermType(resourceType string) string {
switch resourceType {
case "wiki":
return "container"
default:
return ""
}
}
func permissionTargetLabel(resourceType string) string {
switch resourceType {
case "wiki":
return "wiki node"
case "doc", "docx":
return "document"
case "sheet":
return "spreadsheet"
case "bitable", "base":
return "base"
case "file":
return "file"
case "folder":
return "folder"
default:
return "resource"
}
}
func compactPermissionGrantError(err error) string {
if err == nil {
return ""
}
return strings.Join(strings.Fields(err.Error()), " ")
}

View File

@@ -363,26 +363,6 @@ func WrapOpenError(err error, pathMsg, readMsg string) error {
return fmt.Errorf("%s: %w", readMsg, err)
}
// WrapInputStatError wraps a FileIO.Stat/Open error for input file validation,
// returning output.ErrValidation with the appropriate message:
// - Path validation failures → "unsafe file path: ..."
// - Other errors → readMsg prefix (default "cannot read file")
//
// Pass an optional readMsg to override the non-path-validation message prefix.
func WrapInputStatError(err error, readMsg ...string) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe file path: %s", err)
}
msg := "cannot read file"
if len(readMsg) > 0 && readMsg[0] != "" {
msg = readMsg[0]
}
return output.ErrValidation("%s: %s", msg, err)
}
// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
// using standardized messages and the given error category (e.g. "api_error", "io").
// Path validation errors always use ErrValidation (exit code 2).

View File

@@ -5,11 +5,13 @@ package common
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs"
)
// FlagErrorf returns a validation error with flag context (exit code 2).
@@ -86,11 +88,40 @@ func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
// ValidateSafeOutputDir ensures outputDir is a relative path that resolves
// within the current working directory, preventing path traversal attacks
// (including symlink-based escape).
// It delegates all validation to FileIO.ResolvePath which already performs
// cwd-boundary checks, symlink resolution, and control-character rejection.
func ValidateSafeOutputDir(fio fileio.FileIO, outputDir string) error {
_, err := fio.ResolvePath(outputDir)
return err
func ValidateSafeOutputDir(outputDir string) error {
if filepath.IsAbs(outputDir) {
return fmt.Errorf("--output-dir must be a relative path, got: %q", outputDir)
}
cwd, err := vfs.Getwd()
if err != nil {
return fmt.Errorf("cannot determine working directory: %w", err)
}
canonicalCwd, err := filepath.EvalSymlinks(cwd)
if err != nil {
canonicalCwd = cwd
}
abs := filepath.Clean(filepath.Join(cwd, outputDir))
// Resolve symlinks in abs to prevent symlink-escape attacks (e.g. an
// attacker-controlled symlink inside CWD pointing outside).
canonicalAbs, err := filepath.EvalSymlinks(abs)
if err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("--output-dir %q: %w", outputDir, err)
}
// Path does not exist yet. If os.Lstat succeeds the entry is a dangling
// symlink — reject it to prevent future escapes once the target is created.
if _, lstErr := vfs.Lstat(abs); lstErr == nil {
return fmt.Errorf("--output-dir %q is a symlink with a non-existent target", outputDir)
}
// The path itself doesn't exist; the string-level check is sufficient.
canonicalAbs = abs
}
if !strings.HasPrefix(canonicalAbs, canonicalCwd+string(filepath.Separator)) {
return fmt.Errorf("--output-dir %q resolves outside the working directory", outputDir)
}
return nil
}
// RejectDangerousChars returns an error if value contains ASCII control

View File

@@ -8,7 +8,6 @@ import (
"path/filepath"
"testing"
"github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/spf13/cobra"
)
@@ -200,7 +199,7 @@ func TestValidateSafeOutputDir_RejectsSymlinkEscape(t *testing.T) {
t.Fatalf("Symlink: %v", err)
}
if err := ValidateSafeOutputDir(&localfileio.LocalFileIO{}, "evil_out"); err == nil {
if err := ValidateSafeOutputDir("evil_out"); err == nil {
t.Fatal("expected error for symlink pointing outside CWD, got nil")
}
}
@@ -215,7 +214,7 @@ func TestValidateSafeOutputDir_RejectsDanglingSymlink(t *testing.T) {
t.Fatalf("Symlink: %v", err)
}
if err := ValidateSafeOutputDir(&localfileio.LocalFileIO{}, "dangling"); err == nil {
if err := ValidateSafeOutputDir("dangling"); err == nil {
t.Fatal("expected error for dangling symlink, got nil")
}
}
@@ -231,7 +230,7 @@ func TestValidateSafeOutputDir_AllowsNormalSubdir(t *testing.T) {
t.Fatalf("Mkdir: %v", err)
}
if err := ValidateSafeOutputDir(&localfileio.LocalFileIO{}, "output"); err != nil {
if err := ValidateSafeOutputDir("output"); err != nil {
t.Fatalf("expected no error for real subdir, got: %v", err)
}
}
@@ -242,7 +241,7 @@ func TestValidateSafeOutputDir_AllowsNonExistentPath(t *testing.T) {
workDir := t.TempDir()
chdirForTest(t, workDir)
if err := ValidateSafeOutputDir(&localfileio.LocalFileIO{}, "new_output_dir"); err != nil {
if err := ValidateSafeOutputDir("new_output_dir"); err != nil {
t.Fatalf("expected no error for non-existent path, got: %v", err)
}
}

View File

@@ -7,15 +7,28 @@ import (
"context"
"fmt"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"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",
@@ -53,7 +66,8 @@ var DocMediaDownload = common.Shortcut{
if err := validate.ResourceName(token, "--token"); err != nil {
return output.ErrValidation("%s", err)
}
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
// Early path validation before API call (final validation after auto-extension below)
if _, err := validate.SafeOutputPath(outputPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
@@ -77,41 +91,40 @@ var DocMediaDownload = common.Shortcut{
}
defer resp.Body.Close()
fallbackExt := ""
if mediaType == "whiteboard" {
fallbackExt = ".png"
}
finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, fallbackExt)
// Validate final path after extension append
if finalPath != outputPath {
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
// 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"
}
}
// Overwrite check on final path (after extension detection)
if !overwrite {
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath)
}
}
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
safePath, err := validate.SafeOutputPath(finalPath)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
return output.ErrValidation("unsafe output path: %s", err)
}
if err := common.EnsureWritableFile(safePath, overwrite); err != nil {
return err
}
savedPath, _ := runtime.ResolveSavePath(finalPath)
if savedPath == "" {
savedPath = finalPath
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return output.Errorf(output.ExitInternal, "io", "cannot create parent directory: %v", err)
}
sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600)
if err != nil {
return output.Errorf(output.ExitInternal, "io", "cannot create file: %v", err)
}
runtime.Out(map[string]interface{}{
"saved_path": savedPath,
"size_bytes": result.Size(),
"saved_path": safePath,
"size_bytes": sizeBytes,
"content_type": resp.Header.Get("Content-Type"),
}, nil)
return nil

View File

@@ -1,105 +0,0 @@
// 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,
}
}

View File

@@ -8,9 +8,9 @@ import (
"fmt"
"path/filepath"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -79,7 +79,7 @@ var DocMediaInsert = common.Shortcut{
POST("/open-apis/docx/v1/documents/:document_id/blocks/:document_id/children").
Desc(fmt.Sprintf("[%d] Create empty block at document end", stepBase+1)).
Body(createBlockData)
appendDocMediaInsertUploadDryRun(d, runtime.FileIO(), filePath, parentType, stepBase+2)
appendDocMediaInsertUploadDryRun(d, filePath, parentType, stepBase+2)
d.PATCH("/open-apis/docx/v1/documents/:document_id/blocks/batch_update").
Desc(fmt.Sprintf("[%d] Bind uploaded file token to the new block", stepBase+3)).
Body(batchUpdateData)
@@ -93,15 +93,20 @@ var DocMediaInsert = common.Shortcut{
alignStr := runtime.Str("align")
caption := runtime.Str("caption")
safeFilePath, pathErr := validate.SafeInputPath(filePath)
if pathErr != nil {
return output.ErrValidation("unsafe file path: %s", pathErr)
}
documentID, err := resolveDocxDocumentID(runtime, docInput)
if err != nil {
return err
}
// Validate file
stat, err := runtime.FileIO().Stat(filePath)
stat, err := vfs.Stat(safeFilePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
return output.ErrValidation("file not found: %s", filePath)
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
@@ -342,12 +347,12 @@ func extractCreatedBlockTargets(createData map[string]interface{}, mediaType str
return blockID, uploadParentNode, replaceBlockID
}
func appendDocMediaInsertUploadDryRun(d *common.DryRunAPI, fio fileio.FileIO, filePath, parentType string, step int) {
func appendDocMediaInsertUploadDryRun(d *common.DryRunAPI, filePath, parentType string, step int) {
// The upload step runs only after the empty placeholder block is created, so
// dry-run can refer to that future block ID only symbolically. For large
// files, keep multipart internals as substeps of the single user-facing
// "upload file" step.
if docMediaShouldUseMultipart(fio, filePath) {
if docMediaShouldUseMultipart(filePath) {
d.POST("/open-apis/drive/v1/medias/upload_prepare").
Desc(fmt.Sprintf("[%da] Initialize multipart upload", step)).
Body(map[string]interface{}{

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const PreviewType_SOURCE_FILE = "16"
var DocMediaPreview = common.Shortcut{
Service: "docs",
Command: "+media-preview",
Description: "Preview document media file (auto-detects extension)",
Risk: "read",
Scopes: []string{"docs:document.media:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "token", Desc: "media file token", Required: true},
{Name: "output", Desc: "local save path", Required: true},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("token")
outputPath := runtime.Str("output")
return common.NewDryRunAPI().
GET("/open-apis/drive/v1/medias/:token/preview_download").
Desc("Preview document media file").
Params(map[string]interface{}{"preview_type": PreviewType_SOURCE_FILE}).
Set("token", token).Set("output", outputPath)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("token")
outputPath := runtime.Str("output")
overwrite := runtime.Bool("overwrite")
if err := validate.ResourceName(token, "--token"); err != nil {
return output.ErrValidation("%s", err)
}
// Early path validation before API call (final validation after auto-extension below)
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Previewing: media %s\n", common.MaskToken(token))
encodedToken := validate.EncodePathSegment(token)
apiPath := fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", encodedToken)
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: apiPath,
QueryParams: larkcore.QueryParams{
"preview_type": []string{PreviewType_SOURCE_FILE},
},
})
if err != nil {
return output.ErrNetwork("preview failed: %v", err)
}
defer resp.Body.Close()
finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, "")
// Validate final path after extension append
if finalPath != outputPath {
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
}
// Overwrite check on final path (after extension detection)
if !overwrite {
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath)
}
}
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
}
savedPath, _ := runtime.ResolveSavePath(finalPath)
runtime.Out(map[string]interface{}{
"saved_path": savedPath,
"size_bytes": result.Size(),
"content_type": resp.Header.Get("Content-Type"),
}, nil)
return nil
},
}

View File

@@ -18,7 +18,6 @@ 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"
)
@@ -286,289 +285,15 @@ 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", "", "")
cmd.Flags().String("output", "", "")
if err := cmd.Flags().Set("token", "tok_preview"); err != nil {
t.Fatalf("set --token: %v", err)
}
if err := cmd.Flags().Set("output", "./asset"); err != nil {
t.Fatalf("set --output: %v", err)
}
dry := decodeDocDryRun(t, DocMediaPreview.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
if len(dry.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(dry.API))
}
if dry.API[0].Desc != "Preview document media file" {
t.Fatalf("dry-run api desc = %q", dry.API[0].Desc)
}
if dry.API[0].URL != "/open-apis/drive/v1/medias/tok_preview/preview_download" {
t.Fatalf("URL = %q, want media preview endpoint", dry.API[0].URL)
}
if got, _ := dry.API[0].Params["preview_type"].(string); got != PreviewType_SOURCE_FILE {
t.Fatalf("preview_type = %q, want %q", got, PreviewType_SOURCE_FILE)
}
}
func TestDocMediaPreviewRejectsOverwriteWithoutFlag(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-overwrite-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("new"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
if err := os.WriteFile("preview.bin", []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDocs(t, DocMediaPreview, []string{
"+media-preview",
"--token", "tok_123",
"--output", "preview.bin",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected overwrite protection error, got nil")
}
if !strings.Contains(err.Error(), "already exists") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDocMediaPreviewRejectsHTTPErrorBeforeWrite(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/preview_download?preview_type=" + PreviewType_SOURCE_FILE,
Status: 404,
Body: "not found",
Headers: http.Header{"Content-Type": []string{"text/plain"}},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaPreview, []string{
"+media-preview",
"--token", "tok_123",
"--output", "preview.bin",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected HTTP error, got nil")
}
if !strings.Contains(err.Error(), "HTTP 404") {
t.Fatalf("unexpected error: %v", err)
}
if _, statErr := os.Stat(filepath.Join(tmpDir, "preview.bin")); !os.IsNotExist(statErr) {
t.Fatalf("preview target should not be created, statErr=%v", statErr)
}
}
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 {
Desc string `json:"desc"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
Desc string `json:"desc"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `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()
@@ -598,23 +323,3 @@ 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
}

View File

@@ -8,8 +8,9 @@ import (
"fmt"
"path/filepath"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -40,7 +41,7 @@ var MediaUpload = common.Shortcut{
body["extra"] = fmt.Sprintf(`{"drive_route_token":"%s"}`, docId)
}
dry := common.NewDryRunAPI()
if docMediaShouldUseMultipart(runtime.FileIO(), filePath) {
if docMediaShouldUseMultipart(filePath) {
prepareBody := map[string]interface{}{
"file_name": filepath.Base(filePath),
"parent_type": parentType,
@@ -80,10 +81,15 @@ var MediaUpload = common.Shortcut{
parentNode := runtime.Str("parent-node")
docId := runtime.Str("doc-id")
safeFilePath, pathErr := validate.SafeInputPath(filePath)
if pathErr != nil {
return output.ErrValidation("unsafe file path: %s", pathErr)
}
// Validate file
stat, err := runtime.FileIO().Stat(filePath)
stat, err := vfs.Stat(safeFilePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
return output.ErrValidation("file not found: %s", filePath)
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
@@ -141,10 +147,14 @@ func uploadDocMediaFile(runtime *common.RuntimeContext, filePath, fileName strin
})
}
func docMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
func docMediaShouldUseMultipart(filePath string) bool {
// Dry-run uses local stat as a best-effort planning hint. Execute re-validates
// the file before choosing the actual upload path.
info, err := fio.Stat(filePath)
safeFilePath, err := validate.SafeInputPath(filePath)
if err != nil {
return false
}
info, err := vfs.Stat(safeFilePath)
if err != nil {
return false
}

View File

@@ -5,7 +5,6 @@ package doc
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -41,90 +40,51 @@ var DocsCreate = common.Shortcut{
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildDocsCreateArgs(runtime)
d := common.NewDryRunAPI().
args := map[string]interface{}{
"markdown": runtime.Str("markdown"),
}
if v := runtime.Str("title"); v != "" {
args["title"] = v
}
if v := runtime.Str("folder-token"); v != "" {
args["folder_token"] = v
}
if v := runtime.Str("wiki-node"); v != "" {
args["wiki_node"] = v
}
if v := runtime.Str("wiki-space"); v != "" {
args["wiki_space"] = v
}
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: create-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
Set("mcp_tool", "create-doc").Set("args", args)
if runtime.IsBot() {
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
args := buildDocsCreateArgs(runtime)
args := map[string]interface{}{
"markdown": runtime.Str("markdown"),
}
if v := runtime.Str("title"); v != "" {
args["title"] = v
}
if v := runtime.Str("folder-token"); v != "" {
args["folder_token"] = v
}
if v := runtime.Str("wiki-node"); v != "" {
args["wiki_node"] = v
}
if v := runtime.Str("wiki-space"); v != "" {
args["wiki_space"] = v
}
result, err := common.CallMCPTool(runtime, "create-doc", args)
if err != nil {
return err
}
augmentDocsCreateResult(runtime, result)
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
},
}
func buildDocsCreateArgs(runtime *common.RuntimeContext) map[string]interface{} {
args := map[string]interface{}{
"markdown": runtime.Str("markdown"),
}
if v := runtime.Str("title"); v != "" {
args["title"] = v
}
if v := runtime.Str("folder-token"); v != "" {
args["folder_token"] = v
}
if v := runtime.Str("wiki-node"); v != "" {
args["wiki_node"] = v
}
if v := runtime.Str("wiki-space"); v != "" {
args["wiki_space"] = v
}
return args
}
type docsPermissionTarget struct {
Token string
Type string
}
func augmentDocsCreateResult(runtime *common.RuntimeContext, result map[string]interface{}) {
target := selectDocsPermissionTarget(result)
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
result["permission_grant"] = grant
}
}
func selectDocsPermissionTarget(result map[string]interface{}) docsPermissionTarget {
if ref, ok := parseDocsPermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
return ref
}
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
if docID != "" {
return docsPermissionTarget{Token: docID, Type: "docx"}
}
return docsPermissionTarget{}
}
func parseDocsPermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
if strings.TrimSpace(docURL) == "" {
return docsPermissionTarget{}, false
}
ref, err := parseDocumentRef(docURL)
if err != nil {
return docsPermissionTarget{}, false
}
switch ref.Kind {
case "wiki":
return docsPermissionTarget{Token: ref.Token, Type: "wiki"}, true
case "doc", "docx":
return docsPermissionTarget{Token: ref.Token, Type: ref.Kind}, true
default:
return docsPermissionTarget{}, false
}
}

View File

@@ -1,240 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestDocsCreateBotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_current_user",
"member_type": "openid",
"perm": "full_access",
},
},
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目计划",
"--markdown", "## 目标",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestDocsCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 内容",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if _, ok := grant["user_open_id"]; ok {
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
}
}
func TestDocsCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 内容",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/wiki/wikcn_new_node",
"message": "文档创建成功",
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/wikcn_new_node/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 内容",
"--wiki-space", "my_library",
"--as", "bot",
})
if err != nil {
t.Fatalf("document creation should still succeed when auto-grant fails, got: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["perm_type"] != "container" {
t.Fatalf("permission request perm_type = %#v, want %q", body["perm_type"], "container")
}
}
func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
t.Helper()
replacer := strings.NewReplacer("/", "-", " ", "-")
suffix := replacer.Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "test-docs-create-" + suffix,
AppSecret: "secret-docs-create-" + suffix,
Brand: core.BrandFeishu,
UserOpenId: userOpenID,
}
}
func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interface{}) {
payload, _ := json.Marshal(result)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/mcp",
Body: map[string]interface{}{
"result": map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "text",
"text": string(payload),
},
},
},
},
})
}
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "docs"}
DocsCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func decodeDocsCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in output envelope: %#v", envelope)
}
return data
}

View File

@@ -61,10 +61,6 @@ var DocsFetch = common.Shortcut{
return err
}
if md, ok := result["markdown"].(string); ok {
result["markdown"] = fixExportedMarkdown(md)
}
runtime.OutFormat(result, nil, func(w io.Writer) {
if title, ok := result["title"].(string); ok && title != "" {
fmt.Fprintf(w, "# %s\n\n", title)

View File

@@ -168,48 +168,15 @@ func buildDocsSearchRequest(query, filterStr, pageToken, pageSizeStr string) (ma
return nil, err
}
hasFolderTokens := hasNonEmptyFilterArray(filter, "folder_tokens")
hasSpaceIDs := hasNonEmptyFilterArray(filter, "space_ids")
if hasFolderTokens && hasSpaceIDs {
return nil, output.ErrValidation("--filter cannot contain both folder_tokens and space_ids; doc and wiki scoped search cannot be combined")
}
docFilter := cloneFilterMap(filter)
delete(docFilter, "space_ids")
wikiFilter := cloneFilterMap(filter)
delete(wikiFilter, "folder_tokens")
switch {
case hasFolderTokens:
requestData["doc_filter"] = docFilter
case hasSpaceIDs:
requestData["wiki_filter"] = wikiFilter
default:
requestData["doc_filter"] = docFilter
requestData["wiki_filter"] = wikiFilter
requestData["doc_filter"] = filter
wikiFilter := make(map[string]interface{}, len(filter))
for k, v := range filter {
wikiFilter[k] = v
}
requestData["wiki_filter"] = wikiFilter
return requestData, nil
}
func cloneFilterMap(src map[string]interface{}) map[string]interface{} {
dst := make(map[string]interface{}, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func hasNonEmptyFilterArray(filter map[string]interface{}, key string) bool {
val, ok := filter[key]
if !ok || val == nil {
return false
}
items, ok := val.([]interface{})
return ok && len(items) > 0
}
// convertTimeRangeInFilter converts ISO 8601 time range to Unix seconds.
func convertTimeRangeInFilter(filter map[string]interface{}, key string) error {
val, ok := filter[key]

View File

@@ -100,114 +100,3 @@ func TestBuildDocsSearchRequestUsesStartAndEndKeys(t *testing.T) {
t.Fatalf("did not expect end_time in open_time filter, got %#v", openTime)
}
}
func TestBuildDocsSearchRequestKeepsOnlyDocFilterForFolderTokens(t *testing.T) {
t.Parallel()
req, err := buildDocsSearchRequest(
"query",
`{"creator_ids":["ou_123"],"folder_tokens":["fld_123"]}`,
"",
"15",
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
docFilter, ok := req["doc_filter"].(map[string]interface{})
if !ok {
t.Fatalf("doc_filter has unexpected type %T", req["doc_filter"])
}
if _, ok := docFilter["creator_ids"]; !ok {
t.Fatalf("expected creator_ids in doc_filter, got %#v", docFilter)
}
if _, ok := docFilter["folder_tokens"]; !ok {
t.Fatalf("expected folder_tokens in doc_filter, got %#v", docFilter)
}
if _, ok := req["wiki_filter"]; ok {
t.Fatalf("did not expect wiki_filter when folder_tokens is set, got %#v", req["wiki_filter"])
}
}
func TestBuildDocsSearchRequestKeepsOnlyWikiFilterForSpaceIDs(t *testing.T) {
t.Parallel()
req, err := buildDocsSearchRequest(
"query",
`{"creator_ids":["ou_123"],"space_ids":["space_123"]}`,
"",
"15",
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wikiFilter, ok := req["wiki_filter"].(map[string]interface{})
if !ok {
t.Fatalf("wiki_filter has unexpected type %T", req["wiki_filter"])
}
if _, ok := wikiFilter["creator_ids"]; !ok {
t.Fatalf("expected creator_ids in wiki_filter, got %#v", wikiFilter)
}
if _, ok := wikiFilter["space_ids"]; !ok {
t.Fatalf("expected space_ids in wiki_filter, got %#v", wikiFilter)
}
if _, ok := req["doc_filter"]; ok {
t.Fatalf("did not expect doc_filter when space_ids is set, got %#v", req["doc_filter"])
}
}
func TestBuildDocsSearchRequestRejectsMixedFolderTokensAndSpaceIDs(t *testing.T) {
t.Parallel()
_, err := buildDocsSearchRequest(
"query",
`{"creator_ids":["ou_123"],"folder_tokens":["fld_123"],"space_ids":["space_123"]}`,
"",
"15",
)
if err == nil {
t.Fatalf("expected conflict error, got nil")
}
if !strings.Contains(err.Error(), "folder_tokens") || !strings.Contains(err.Error(), "space_ids") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestBuildDocsSearchRequestStripsOppositeScopedKeys(t *testing.T) {
t.Parallel()
docReq, err := buildDocsSearchRequest(
"query",
`{"creator_ids":["ou_123"],"folder_tokens":["fld_123"],"space_ids":[]}`,
"",
"15",
)
if err != nil {
t.Fatalf("unexpected doc request error: %v", err)
}
docFilter, ok := docReq["doc_filter"].(map[string]interface{})
if !ok {
t.Fatalf("doc_filter has unexpected type %T", docReq["doc_filter"])
}
if _, ok := docFilter["space_ids"]; ok {
t.Fatalf("did not expect space_ids in doc_filter, got %#v", docFilter)
}
wikiReq, err := buildDocsSearchRequest(
"query",
`{"creator_ids":["ou_123"],"space_ids":["space_123"],"folder_tokens":[]}`,
"",
"15",
)
if err != nil {
t.Fatalf("unexpected wiki request error: %v", err)
}
wikiFilter, ok := wikiReq["wiki_filter"].(map[string]interface{})
if !ok {
t.Fatalf("wiki_filter has unexpected type %T", wikiReq["wiki_filter"])
}
if _, ok := wikiFilter["folder_tokens"]; ok {
t.Fatalf("did not expect folder_tokens in wiki_filter, got %#v", wikiFilter)
}
}

View File

@@ -1,416 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"regexp"
"strings"
)
// fixExportedMarkdown applies post-processing to Lark-exported Markdown to
// improve round-trip fidelity on re-import:
//
// 1. fixBoldSpacing: removes trailing whitespace before closing ** / *,
// and strips redundant ** from ATX headings. Applied only outside fenced
// code blocks, and skips inline code spans.
//
// 2. fixSetextAmbiguity: inserts a blank line before any "---" that immediately
// follows a non-empty line, preventing it from being parsed as a Setext H2.
// Applied only outside fenced code blocks.
//
// 3. fixBlockquoteHardBreaks: inserts a blank blockquote line (">") between
// consecutive blockquote content lines so create-doc preserves line breaks.
// Applied only outside fenced code blocks.
//
// 4. fixTopLevelSoftbreaks: inserts a blank line between adjacent non-empty
// lines at the top level and inside content containers (callout,
// quote-container, lark-td). Code fences are left untouched, and
// consecutive list items / continuations are not separated.
//
// 5. fixCalloutEmoji: replaces named emoji aliases (e.g. emoji="warning") with
// actual Unicode emoji characters that create-doc understands. Applied only
// outside fenced code blocks.
func fixExportedMarkdown(md string) string {
md = applyOutsideCodeFences(md, fixBoldSpacing)
md = applyOutsideCodeFences(md, fixSetextAmbiguity)
md = applyOutsideCodeFences(md, fixBlockquoteHardBreaks)
md = fixTopLevelSoftbreaks(md)
md = applyOutsideCodeFences(md, fixCalloutEmoji)
// Collapse runs of 3+ consecutive newlines into exactly 2 (one blank line),
// but only outside fenced code blocks to preserve intentional blank lines in code.
md = applyOutsideCodeFences(md, func(s string) string {
for strings.Contains(s, "\n\n\n") {
s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
}
return s
})
md = strings.TrimRight(md, "\n") + "\n"
return md
}
// applyOutsideCodeFences applies fn only to content outside fenced code blocks.
// Lines inside fenced code blocks (``` ... ```) are passed through unchanged,
// preventing transforms from corrupting literal code content.
func applyOutsideCodeFences(md string, fn func(string) string) string {
lines := strings.Split(md, "\n")
var out []string
var chunk []string
inCode := false
flush := func() {
if len(chunk) == 0 {
return
}
out = append(out, strings.Split(fn(strings.Join(chunk, "\n")), "\n")...)
chunk = chunk[:0]
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
if !inCode {
flush()
inCode = true
} else if trimmed == "```" {
inCode = false
}
out = append(out, line)
continue
}
if inCode {
out = append(out, line)
} else {
chunk = append(chunk, line)
}
}
flush()
return strings.Join(out, "\n")
}
// fixBlockquoteHardBreaks inserts a blank blockquote line (">") between
// consecutive blockquote content lines. This forces each line into its own
// paragraph within the blockquote, so MCP create-doc preserves line breaks
// instead of collapsing them into a single paragraph.
//
// Before: "> line1\n> line2" → After: "> line1\n>\n> line2"
func fixBlockquoteHardBreaks(md string) string {
lines := strings.Split(md, "\n")
out := make([]string, 0, len(lines)*2)
for i, line := range lines {
out = append(out, line)
if strings.HasPrefix(line, "> ") && i+1 < len(lines) && strings.HasPrefix(lines[i+1], "> ") {
out = append(out, ">")
}
}
return strings.Join(out, "\n")
}
// fixBoldSpacing fixes two issues with bold markers exported by Lark:
//
// 1. Trailing whitespace before closing **: "**text **" → "**text**"
// CommonMark requires no space before a closing delimiter; otherwise the
// ** is rendered as literal text.
//
// 2. Redundant bold in ATX headings: "# **text**" → "# text"
// Headings are already bold, so the inner ** is visually redundant and
// some renderers display the markers literally.
//
// Both fixes skip inline code spans to avoid modifying literal code content.
var (
boldTrailingSpaceRe = regexp.MustCompile(`(\*\*\S[^*]*?)\s+(\*\*)`)
italicTrailingSpaceRe = regexp.MustCompile(`(\*\S[^*]*?)\s+(\*)`)
// headingBoldRe uses [^*]+ (no asterisks) to avoid mismatching headings
// that contain multiple disjoint bold spans such as "# **foo** and **bar**".
headingBoldRe = regexp.MustCompile(`(?m)^(#{1,6})\s+\*\*([^*]+)\*\*\s*$`)
)
func fixBoldSpacing(md string) string {
lines := strings.Split(md, "\n")
for i, line := range lines {
lines[i] = fixBoldSpacingLine(line)
}
md = strings.Join(lines, "\n")
md = headingBoldRe.ReplaceAllString(md, "$1 $2")
return md
}
// atxHeadingRe matches ATX heading lines (# ... through ###### ...).
var atxHeadingRe = regexp.MustCompile(`^#{1,6}\s`)
// scanInlineCodeSpans returns the byte ranges [start, end) of all inline code
// spans in line. It handles multi-backtick delimiters (e.g. “ `foo` “) by
// finding the opening run of N backticks and searching for the next identical
// run to close the span, per CommonMark spec §6.1.
func scanInlineCodeSpans(line string) [][2]int {
var spans [][2]int
i := 0
for i < len(line) {
if line[i] != '`' {
i++
continue
}
// Count the opening backtick run.
start := i
for i < len(line) && line[i] == '`' {
i++
}
delim := line[start:i] // e.g. "`" or "``" or "```"
// Search for the closing run of the same length.
j := i
for j <= len(line)-len(delim) {
if line[j] == '`' {
k := j
for k < len(line) && line[k] == '`' {
k++
}
if k-j == len(delim) {
spans = append(spans, [2]int{start, k})
i = k
break
}
j = k // skip this backtick run and keep searching
} else {
j++
}
}
// No closing delimiter found — not a code span, continue.
}
return spans
}
// fixBoldSpacingLine applies bold/italic trailing-space fixes to a single line,
// skipping content inside inline code spans to avoid corrupting literal code.
// ATX heading lines are also skipped here because headingBoldRe in fixBoldSpacing
// handles them separately and boldTrailingSpaceRe can misfire on headings with
// multiple disjoint bold spans (e.g. "# **foo** and **bar**").
func fixBoldSpacingLine(line string) string {
if atxHeadingRe.MatchString(line) {
return line
}
spans := scanInlineCodeSpans(line)
if len(spans) == 0 {
line = boldTrailingSpaceRe.ReplaceAllString(line, "$1$2")
line = italicTrailingSpaceRe.ReplaceAllString(line, "$1$2")
return line
}
var sb strings.Builder
pos := 0
for _, loc := range spans {
// Process the non-code segment before this inline code span.
seg := line[pos:loc[0]]
seg = boldTrailingSpaceRe.ReplaceAllString(seg, "$1$2")
seg = italicTrailingSpaceRe.ReplaceAllString(seg, "$1$2")
sb.WriteString(seg)
// Preserve inline code span as-is.
sb.WriteString(line[loc[0]:loc[1]])
pos = loc[1]
}
// Remaining non-code segment after the last code span.
seg := line[pos:]
seg = boldTrailingSpaceRe.ReplaceAllString(seg, "$1$2")
seg = italicTrailingSpaceRe.ReplaceAllString(seg, "$1$2")
sb.WriteString(seg)
return sb.String()
}
var setextRe = regexp.MustCompile(`(?m)^([^\n]+)\n(-{3,}\s*$)`)
func fixSetextAmbiguity(md string) string {
return setextRe.ReplaceAllString(md, "$1\n\n$2")
}
// calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual
// Unicode emoji characters that create-doc accepts.
var calloutEmojiAliases = map[string]string{
"warning": "⚠️",
"note": "📝",
"tip": "💡",
"info": "",
"check": "✅",
"success": "✅",
"error": "❌",
"danger": "🚨",
"important": "❗",
"caution": "⚠️",
"question": "❓",
"forbidden": "🚫",
"fire": "🔥",
"star": "⭐",
"pin": "📌",
"clock": "🕐",
"gift": "🎁",
"eyes": "👀",
"bulb": "💡",
"memo": "📝",
"link": "🔗",
"key": "🔑",
"lock": "🔒",
"thumbsup": "👍",
"thumbsdown": "👎",
"rocket": "🚀",
"construction": "🚧",
}
// calloutEmojiRe matches emoji="<name>" in callout opening tags.
var calloutEmojiRe = regexp.MustCompile(`(<callout[^>]*\bemoji=")([^"]+)(")`)
// fixCalloutEmoji replaces named emoji aliases in callout tags with actual
// Unicode emoji characters. fetch-doc sometimes emits emoji="warning" instead
// of emoji="⚠️"; create-doc only accepts Unicode emoji.
func fixCalloutEmoji(md string) string {
return calloutEmojiRe.ReplaceAllStringFunc(md, func(match string) string {
parts := calloutEmojiRe.FindStringSubmatch(match)
if len(parts) != 4 {
return match
}
name := parts[2]
if emoji, ok := calloutEmojiAliases[name]; ok {
return parts[1] + emoji + parts[3]
}
return match
})
}
// isTableStructuralTag returns true for lark-table tags that are structural
// (table/tr/td open/close) and should not themselves trigger blank-line insertion.
func isTableStructuralTag(s string) bool {
return strings.HasPrefix(s, "<lark-t") ||
strings.HasPrefix(s, "</lark-t")
}
// contentContainers lists block tags whose interior should have blank lines
// inserted between adjacent content lines (same treatment as lark-td).
var contentContainers = [][2]string{
{"<lark-td>", "</lark-td>"},
{"<callout", "</callout>"},
{"<quote-container>", "</quote-container>"},
}
// listItemRe matches unordered and ordered list item markers, including
// indented (nested) items.
var listItemRe = regexp.MustCompile(`^[ \t]*([-*+]|\d+[.)]) `)
// isListItemOrContinuation returns true for lines that are part of a list:
// either a list item marker line or an indented continuation of a list item.
// This is used to prevent blank lines being inserted between tight list lines,
// which would turn a tight list into a loose list and change rendering.
func isListItemOrContinuation(line string) bool {
if listItemRe.MatchString(line) {
return true
}
// Continuation lines are indented by at least 2 spaces or 1 tab.
return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
}
// fixTopLevelSoftbreaks ensures that adjacent non-empty content lines are
// separated by a blank line in the following contexts:
// 1. Top level (depth == 0): every Lark block becomes its own Markdown paragraph.
// 2. Inside content containers (<lark-td>, <callout>, <quote-container>):
// multi-line content is preserved as separate paragraphs.
//
// Structural table tags (<lark-table>, <lark-tr>, <lark-td> and their closing
// counterparts) never trigger blank-line insertion themselves. Fenced code
// blocks (``` ... ```) are left completely untouched. Consecutive list items
// and list continuations are not separated (to preserve tight lists).
func fixTopLevelSoftbreaks(md string) string {
lines := strings.Split(md, "\n")
out := make([]string, 0, len(lines)*2)
inCodeBlock := false
// containerDepth > 0 means we are inside a content container.
containerDepth := 0
// tableDepth tracks <lark-table> nesting (outer structure, not content).
tableDepth := 0
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// --- Track fenced code blocks — skip all processing inside. ---
// Any ``` line opens a block; only plain ``` (no language id) closes it.
if strings.HasPrefix(trimmed, "```") {
if inCodeBlock {
if trimmed == "```" {
inCodeBlock = false
}
} else {
inCodeBlock = true
}
out = append(out, line)
continue
}
if !inCodeBlock {
// --- Track content containers. ---
for _, cc := range contentContainers {
if strings.HasPrefix(trimmed, cc[0]) {
containerDepth++
}
if strings.Contains(trimmed, cc[1]) {
containerDepth--
if containerDepth < 0 {
containerDepth = 0
}
}
}
// --- Track table structure (outer, non-content). ---
if strings.HasPrefix(trimmed, "<lark-table") {
tableDepth++
}
if strings.Contains(trimmed, "</lark-table>") {
tableDepth--
if tableDepth < 0 {
tableDepth = 0
}
}
}
// --- Decide whether to insert a blank line before this line. ---
if !inCodeBlock && trimmed != "" && i > 0 {
// Skip structural table tags — they are not content lines.
isStructural := isTableStructuralTag(trimmed)
// Don't split consecutive blockquote lines ("> ...") — they form
// one continuous blockquote in the original document.
isBlockquote := strings.HasPrefix(trimmed, "> ") || trimmed == ">"
// Only closing container tags suppress blank-line insertion.
// Opening container tags may still receive a blank line before them
// (e.g. two consecutive <callout> blocks need a blank between them).
isContainerTag := false
for _, cc := range contentContainers {
closingTag := "</" + cc[0][1:]
if strings.HasPrefix(trimmed, closingTag) {
isContainerTag = true
break
}
}
// Insert blank line when:
// - at top level (tableDepth == 0, containerDepth == 0), OR
// - inside a content container (containerDepth > 0, not in outer table)
// AND this line is actual content (not structural/blockquote/container-tag).
inContent := tableDepth == 0 || containerDepth > 0
if !isStructural && !isBlockquote && !isContainerTag && inContent {
// Don't split consecutive list items / continuations — inserting a
// blank line between them turns a tight list into a loose list.
isListRelated := isListItemOrContinuation(line)
prevIsListRelated := len(out) > 0 && isListItemOrContinuation(out[len(out)-1])
if !(isListRelated && prevIsListRelated) {
prev := ""
if len(out) > 0 {
prev = strings.TrimSpace(out[len(out)-1])
}
if prev != "" && !isTableStructuralTag(prev) {
out = append(out, "")
}
}
}
}
out = append(out, line)
}
return strings.Join(out, "\n")
}

View File

@@ -1,333 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"strings"
"testing"
)
func TestFixBoldSpacing(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "trailing space before closing bold",
input: "**hello **",
want: "**hello**",
},
{
name: "trailing space before closing italic",
input: "*hello *",
want: "*hello*",
},
{
name: "redundant bold in h1",
input: "# **Title**",
want: "# Title",
},
{
name: "redundant bold in h2",
input: "## **Section**",
want: "## Section",
},
{
name: "no change needed for clean bold",
input: "**bold**",
want: "**bold**",
},
{
name: "multiple lines processed independently",
input: "**foo **\n**bar **",
want: "**foo**\n**bar**",
},
{
name: "inline code span not modified",
input: "`**hello **`",
want: "`**hello **`",
},
{
name: "inline code preserved, bold outside fixed",
input: "**foo ** and `**bar **`",
want: "**foo** and `**bar **`",
},
{
name: "double-backtick inline code not modified",
input: "``**hello **`` and **world **",
want: "``**hello **`` and **world**",
},
{
name: "double-backtick span containing literal backtick not modified",
input: "`` a`b `` and **bold **",
want: "`` a`b `` and **bold**",
},
{
name: "heading with multiple bold spans left unchanged",
input: "# **foo** and **bar**",
want: "# **foo** and **bar**",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := fixBoldSpacing(tt.input)
if got != tt.want {
t.Errorf("fixBoldSpacing(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFixSetextAmbiguity(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "paragraph followed by ---",
input: "some text\n---",
want: "some text\n\n---",
},
{
name: "blank line before --- already",
input: "some text\n\n---",
want: "some text\n\n---",
},
{
name: "heading not affected",
input: "# Heading\n---",
want: "# Heading\n\n---",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := fixSetextAmbiguity(tt.input)
if got != tt.want {
t.Errorf("fixSetextAmbiguity(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFixBlockquoteHardBreaks(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "two consecutive blockquote lines",
input: "> line1\n> line2",
want: "> line1\n>\n> line2",
},
{
name: "three consecutive blockquote lines",
input: "> a\n> b\n> c",
want: "> a\n>\n> b\n>\n> c",
},
{
name: "single blockquote line unchanged",
input: "> only one",
want: "> only one",
},
{
name: "non-blockquote not affected",
input: "line1\nline2",
want: "line1\nline2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := fixBlockquoteHardBreaks(tt.input)
if got != tt.want {
t.Errorf("fixBlockquoteHardBreaks(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFixTopLevelSoftbreaks(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "adjacent top-level lines get blank line",
input: "paragraph one\nparagraph two",
want: "paragraph one\n\nparagraph two",
},
{
name: "lines inside code block not modified",
input: "```\nline1\nline2\n```",
want: "```\nline1\nline2\n```",
},
{
// callout is a content container: blank lines are inserted between inner lines.
name: "lines inside callout get blank line between them",
input: "<callout>\nline1\nline2\n</callout>",
want: "<callout>\n\nline1\n\nline2\n</callout>",
},
{
name: "lark-td cell content gets blank line",
input: "<lark-td>\nline1\nline2\n</lark-td>",
want: "<lark-td>\nline1\n\nline2\n</lark-td>",
},
{
name: "structural lark-table tags not separated",
input: "<lark-table>\n<lark-tr>\n<lark-td>\ncontent\n</lark-td>\n</lark-tr>\n</lark-table>",
want: "<lark-table>\n<lark-tr>\n<lark-td>\ncontent\n</lark-td>\n</lark-tr>\n</lark-table>",
},
{
name: "blockquote lines not split",
input: "> line1\n> line2",
want: "> line1\n> line2",
},
{
name: "consecutive unordered list items not split",
input: "- item a\n- item b\n- item c",
want: "- item a\n- item b\n- item c",
},
{
name: "consecutive ordered list items not split",
input: "1. first\n2. second\n3. third",
want: "1. first\n2. second\n3. third",
},
{
name: "list continuation not split from item",
input: "- item a\n continuation",
want: "- item a\n continuation",
},
{
name: "text to list transition gets blank line",
input: "paragraph\n- list item",
want: "paragraph\n\n- list item",
},
{
name: "adjacent callout blocks get blank line between them",
input: "<callout>\ncontent1\n</callout>\n<callout>\ncontent2\n</callout>",
want: "<callout>\n\ncontent1\n</callout>\n\n<callout>\n\ncontent2\n</callout>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := fixTopLevelSoftbreaks(tt.input)
if got != tt.want {
t.Errorf("fixTopLevelSoftbreaks(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFixExportedMarkdown(t *testing.T) {
// End-to-end: all fixes applied together
input := "# **Title**\nparagraph one\nparagraph two\n**bold **\n> q1\n> q2\nsome text\n---"
result := fixExportedMarkdown(input)
if strings.Contains(result, "# **Title**") {
t.Error("expected heading bold to be stripped")
}
if !strings.Contains(result, "paragraph one\n\nparagraph two") {
t.Error("expected blank line between top-level paragraphs")
}
if strings.Contains(result, "**bold **") {
t.Error("expected trailing space in bold to be fixed")
}
if !strings.Contains(result, ">\n> q2") {
t.Error("expected blockquote hard break inserted")
}
if strings.Contains(result, "some text\n---") {
t.Error("expected blank line before --- to prevent setext heading")
}
// Should end with exactly one newline
if !strings.HasSuffix(result, "\n") || strings.HasSuffix(result, "\n\n") {
t.Errorf("expected result to end with exactly one newline, got %q", result[len(result)-5:])
}
// No triple newlines
if strings.Contains(result, "\n\n\n") {
t.Error("expected no triple newlines in output")
}
}
func TestFixCalloutEmoji(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "warning alias replaced",
input: `<callout emoji="warning" background-color="light-orange">`,
want: `<callout emoji="⚠️" background-color="light-orange">`,
},
{
name: "tip alias replaced",
input: `<callout emoji="tip">`,
want: `<callout emoji="💡">`,
},
{
name: "actual emoji unchanged",
input: `<callout emoji="⚠️">`,
want: `<callout emoji="⚠️">`,
},
{
name: "unknown alias unchanged",
input: `<callout emoji="unicorn">`,
want: `<callout emoji="unicorn">`,
},
{
name: "non-callout tag unchanged",
input: `<div emoji="warning">`,
want: `<div emoji="warning">`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := fixCalloutEmoji(tt.input)
if got != tt.want {
t.Errorf("fixCalloutEmoji(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestApplyOutsideCodeFences(t *testing.T) {
// Transforms should not modify content inside fenced code blocks.
input := "```md\n**x **\n> a\n> b\nline\n---\n```"
if got := applyOutsideCodeFences(input, fixBoldSpacing); got != input {
t.Fatalf("fixBoldSpacing (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input)
}
if got := applyOutsideCodeFences(input, fixSetextAmbiguity); got != input {
t.Fatalf("fixSetextAmbiguity (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input)
}
if got := applyOutsideCodeFences(input, fixBlockquoteHardBreaks); got != input {
t.Fatalf("fixBlockquoteHardBreaks (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input)
}
// Content outside the fence should still be transformed.
mixed := "**foo ** before\n```\n**x **\n```\n**bar ** after"
got := applyOutsideCodeFences(mixed, fixBoldSpacing)
if strings.Contains(got, "**foo **") {
t.Errorf("fixBoldSpacing did not fix bold before fence: %q", got)
}
if strings.Contains(got, "**bar **") {
t.Errorf("fixBoldSpacing did not fix bold after fence: %q", got)
}
if !strings.Contains(got, "```\n**x **\n```") {
t.Errorf("fixBoldSpacing modified content inside fence: %q", got)
}
}
func TestFixTopLevelSoftbreaksQuoteContainer(t *testing.T) {
input := "<quote-container>\nline1\nline2\n</quote-container>"
got := fixTopLevelSoftbreaks(input)
// quote-container is a content container: blank lines inserted between inner lines.
want := "<quote-container>\n\nline1\n\nline2\n</quote-container>"
if got != want {
t.Errorf("fixTopLevelSoftbreaks quote-container = %q, want %q", got, want)
}
}

View File

@@ -13,7 +13,6 @@ func Shortcuts() []common.Shortcut {
DocsFetch,
DocsUpdate,
DocMediaInsert,
DocMediaPreview,
DocMediaDownload,
}
}

View File

@@ -7,12 +7,13 @@ import (
"context"
"fmt"
"net/http"
"path/filepath"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -50,13 +51,12 @@ var DriveDownload = common.Shortcut{
if outputPath == "" {
outputPath = fileToken
}
// Early path validation + overwrite check
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
return output.ErrValidation("unsafe output path: %s", resolveErr)
safePath, err := validate.SafeOutputPath(outputPath)
if err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
if err := common.EnsureWritableFile(safePath, overwrite); err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken))
@@ -70,21 +70,18 @@ var DriveDownload = common.Shortcut{
}
defer resp.Body.Close()
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err)
}
savedPath, _ := runtime.ResolveSavePath(outputPath)
if savedPath == "" {
savedPath = outputPath
sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600)
if err != nil {
return output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err)
}
runtime.Out(map[string]interface{}{
"saved_path": savedPath,
"size_bytes": result.Size(),
"saved_path": safePath,
"size_bytes": sizeBytes,
}, nil)
return nil
},

View File

@@ -114,7 +114,7 @@ var DriveExport = common.Shortcut{
title = spec.Token
}
fileName := ensureExportFileExtension(sanitizeExportFileName(title, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
savedPath, err := saveContentToOutputDir(outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
if err != nil {
return err
}

View File

@@ -4,7 +4,6 @@
package drive
import (
"bytes"
"context"
"fmt"
"net/http"
@@ -15,10 +14,10 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -253,8 +252,8 @@ func fetchDriveMetaTitle(runtime *common.RuntimeContext, token, docType string)
}
// saveContentToOutputDir validates the target path, enforces overwrite policy,
// and writes the payload atomically via FileIO.Save.
func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, payload []byte, overwrite bool) (string, error) {
// and writes the payload atomically to disk.
func saveContentToOutputDir(outputDir, fileName string, payload []byte, overwrite bool) (string, error) {
if outputDir == "" {
outputDir = "."
}
@@ -263,22 +262,21 @@ func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, paylo
// names cannot escape the requested output directory.
safeName := sanitizeExportFileName(fileName, "export.bin")
target := filepath.Join(outputDir, safeName)
// Overwrite check via FileIO.Stat
if !overwrite {
if _, statErr := fio.Stat(target); statErr == nil {
return "", output.ErrValidation("output file already exists: %s (use --overwrite to replace)", target)
}
safePath, err := validate.SafeOutputPath(target)
if err != nil {
return "", output.ErrValidation("unsafe output path: %s", err)
}
if err := common.EnsureWritableFile(safePath, overwrite); err != nil {
return "", err
}
if _, err := fio.Save(target, fileio.SaveOptions{}, bytes.NewReader(payload)); err != nil {
return "", common.WrapSaveErrorByCategory(err, "io")
if err := vfs.MkdirAll(filepath.Dir(safePath), 0755); err != nil {
return "", output.Errorf(output.ExitInternal, "io", "cannot create output directory: %s", err)
}
resolvedPath, _ := fio.ResolvePath(target)
if resolvedPath == "" {
resolvedPath = target
if err := validate.AtomicWrite(safePath, payload, 0644); err != nil {
return "", output.Errorf(output.ExitInternal, "io", "cannot write file: %s", err)
}
return resolvedPath, nil
return safePath, nil
}
// downloadDriveExportFile downloads the exported artifact, derives a safe local
@@ -305,7 +303,7 @@ func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext
// request an explicit local file name.
fileName = client.ResolveFilename(apiResp)
}
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, apiResp.RawBody, overwrite)
savedPath, err := saveContentToOutputDir(outputDir, fileName, apiResp.RawBody, overwrite)
if err != nil {
return nil, err
}

View File

@@ -15,7 +15,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
func TestValidateDriveExportSpec(t *testing.T) {
@@ -466,8 +465,7 @@ func TestSaveContentToOutputDirRejectsOverwriteWithoutFlag(t *testing.T) {
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
fio := &localfileio.LocalFileIO{}
_, err = saveContentToOutputDir(fio, ".", "exists.txt", []byte("new"), false)
_, err = saveContentToOutputDir(".", "exists.txt", []byte("new"), false)
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("expected overwrite error, got %v", err)
}

View File

@@ -9,8 +9,10 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,7 +49,7 @@ var DriveImport = common.Shortcut{
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
}
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
fileSize, err := preflightDriveImportFile(&spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
@@ -64,9 +66,6 @@ var DriveImport = common.Shortcut{
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry
},
@@ -77,7 +76,7 @@ var DriveImport = common.Shortcut{
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
}
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
if _, err := preflightDriveImportFile(&spec); err != nil {
return err
}
@@ -134,23 +133,23 @@ var DriveImport = common.Shortcut{
out["timed_out"] = true
out["next_command"] = nextCommand
}
if ready {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
return nil
},
}
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
func preflightDriveImportFile(spec *driveImportSpec) (int64, error) {
// Keep dry-run and execution aligned on path normalization, file existence,
// and format-specific size limits before planning the upload path.
info, err := fio.Stat(spec.FilePath)
safeFilePath, err := validate.SafeInputPath(spec.FilePath)
if err != nil {
return 0, common.WrapInputStatError(err)
return 0, output.ErrValidation("unsafe file path: %s", err)
}
info, err := vfs.Stat(safeFilePath)
if err != nil {
return 0, output.ErrValidation("cannot read file: %s", err)
}
if !info.Mode().IsRegular() {
return 0, output.ErrValidation("file must be a regular file: %s", spec.FilePath)

View File

@@ -11,6 +11,8 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -83,9 +85,9 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{}
// uploadMediaForImport uploads the source file to the temporary import media
// endpoint and returns the file token consumed by import_tasks.
func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) {
importInfo, err := runtime.FileIO().Stat(filePath)
importInfo, err := vfs.Stat(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
return "", output.ErrValidation("cannot read file: %s", err)
}
fileSize := importInfo.Size()

View File

@@ -196,9 +196,6 @@ func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario import --ticket tk_import"`)) {
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
}
if bytes.Contains(stdout.Bytes(), []byte(`"permission_grant"`)) {
t.Fatalf("stdout should not include permission_grant before import is ready: %s", stdout.String())
}
}
func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {

View File

@@ -12,7 +12,6 @@ import (
"github.com/spf13/cobra"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/larksuite/cli/shortcuts/common"
)

View File

@@ -1,244 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"bytes"
"encoding/json"
"os"
"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"
)
func TestDriveUploadBotAutoGrantSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"file_token": "file_uploaded",
},
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/file_uploaded/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
}
reg.Register(permStub)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("report.pdf", []byte("pdf"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "report.pdf",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new file." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
body := decodeCapturedJSONBody(t, permStub)
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestDriveImportBotAutoGrantSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"file_token": "file_media",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/import_tasks",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"ticket": "tk_import",
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_import",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"result": map[string]interface{}{
"type": "docx",
"job_status": 0,
"token": "doxcn_imported",
"url": "https://example.feishu.cn/docx/doxcn_imported",
},
},
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_imported/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
}
reg.Register(permStub)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("README.md", []byte("# Title"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
prevAttempts, prevInterval := driveImportPollAttempts, driveImportPollInterval
driveImportPollAttempts, driveImportPollInterval = 1, 0
t.Cleanup(func() {
driveImportPollAttempts, driveImportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "README.md",
"--type", "docx",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
body := decodeCapturedJSONBody(t, permStub)
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestDriveUploadUserSkipsPermissionGrantAugmentation(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"file_token": "file_uploaded",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("report.pdf", []byte("pdf"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "report.pdf",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func drivePermissionGrantTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
t.Helper()
replacer := strings.NewReplacer("/", "-", " ", "-")
suffix := replacer.Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "drive-permission-test-" + suffix,
AppSecret: "drive-permission-secret-" + suffix,
Brand: core.BrandFeishu,
UserOpenId: userOpenID,
}
}
func decodeDriveEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in output envelope: %#v", envelope)
}
return data
}
func registerDriveBotTokenStub(reg *httpmock.Registry) {
_ = reg
}
func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("failed to decode captured request body: %v\nraw=%s", err, string(stub.CapturedBody))
}
return body
}

View File

@@ -111,7 +111,7 @@ var DriveTaskResult = common.Shortcut{
// the CLI surface uniform for resume-on-timeout workflows.
switch scenario {
case "import":
result, err = queryImportTaskAndAutoGrantPermission(runtime, ticket)
result, err = queryImportTask(runtime, ticket)
case "export":
result, err = queryExportTask(runtime, ticket, fileToken)
case "task_check":
@@ -127,16 +127,14 @@ var DriveTaskResult = common.Shortcut{
},
}
// queryImportTaskAndAutoGrantPermission returns a stable, shortcut-friendly
// view of the import task and, in bot mode, retries the current-user
// permission grant once the imported cloud document becomes ready.
func queryImportTaskAndAutoGrantPermission(runtime *common.RuntimeContext, ticket string) (map[string]interface{}, error) {
// queryImportTask returns a stable, shortcut-friendly view of the import task.
func queryImportTask(runtime *common.RuntimeContext, ticket string) (map[string]interface{}, error) {
status, err := getDriveImportStatus(runtime, ticket)
if err != nil {
return nil, err
}
result := map[string]interface{}{
return map[string]interface{}{
"scenario": "import",
"ticket": status.Ticket,
"type": status.DocType,
@@ -148,13 +146,7 @@ func queryImportTaskAndAutoGrantPermission(runtime *common.RuntimeContext, ticke
"token": status.Token,
"url": status.URL,
"extra": status.Extra,
}
if status.Ready() {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, status.Token, status.DocType); grant != nil {
result["permission_grant"] = grant
}
}
return result, nil
}, nil
}
// queryExportTask returns the export task status together with download metadata

View File

@@ -156,64 +156,6 @@ func TestDriveTaskResultImportIncludesReadyFlags(t *testing.T) {
if !bytes.Contains(stdout.Bytes(), []byte(`"job_status_label": "processing"`)) {
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
}
if bytes.Contains(stdout.Bytes(), []byte(`"permission_grant"`)) {
t.Fatalf("stdout should not include permission_grant before import is ready: %s", stdout.String())
}
}
func TestDriveTaskResultImportBotAutoGrantSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_import_ready",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"type": "sheet",
"job_status": 0,
"token": "sheet_imported",
"url": "https://example.feishu.cn/sheets/sheet_imported",
},
},
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/sheet_imported/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
}
reg.Register(permStub)
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "import",
"--ticket", "tk_import_ready",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
body := decodeCapturedJSONBody(t, permStub)
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestDriveTaskResultTaskCheckIncludesReadyFlags(t *testing.T) {

Some files were not shown because too many files have changed in this diff Show More