Compare commits

...

23 Commits

Author SHA1 Message Date
liangshuo-1
69cf9f206e chore: release v1.0.7 (#375)
Change-Id: I0568fc87795a821802fe793802fc64ac55def6d6
2026-04-09 21:35:34 +08:00
wittam-01
99b8aaa556 feat: improve doc media extension inference (#364)
Change-Id: Ifc7c0e7844908b88e2d527e0933d080b140a50eb
2026-04-09 21:11:47 +08:00
kongenpei
b4a26b2cdc fix(base): unify --json help format with tips and agent hints (#372)
* fix(base): improve --json help examples and group guide

* fix(base): unify --json help tips format

* docs(base): fix view-set-group schema with group_config

* fix(base): remove array wording from view-set-group json help

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-09 21:06:56 +08:00
liangshuo-1
619ec8c2cb fix(api): support stdin and quoted JSON inputs on Windows (#367)
* fix(api): add stdin and single-quote support for --params/--data on Windows (#64)

Windows PowerShell 5.x mangles JSON double-quotes when passing arguments
to native executables, causing --params and --data to fail with
"invalid JSON format". This commit adds two mitigations at the framework
level:

- stdin piping: `echo '{"k":"v"}' | lark-cli --params -` bypasses
  shell argument parsing entirely and works on all platforms/shells.
- single-quote stripping: cmd.exe passes literal single quotes which
  are now transparently removed before JSON parsing.

Implementation:
- New `cmdutil.ResolveInput(raw, stdin)` handles `-` (stdin), strip
  surrounding `'...'`, and plain passthrough.
- `ParseJSONMap` and `ParseOptionalBody` now accept an `io.Reader` and
  delegate to `ResolveInput` before JSON unmarshalling.
- `cmd/api` and `cmd/service` pass `IOStreams.In` and guard against
  simultaneous stdin usage by --params and --data.
- Empty stdin is rejected with a clear error message.

Closes #64

Change-Id: If21e735d0aed5c6a2d6674c1e6c898186fca3aba

* test: add stdin e2e regression coverage

Change-Id: I4e00bf1c6b6f3259f503e3414cae10fa4b34ba75
2026-04-09 19:10:50 +08:00
liujinkun2025
eb3c643f0b route base import guidance to drive +import (#368)
Change-Id: I12f86a343d79b8fb480084305ed34b54aa92fb94
2026-04-09 18:22:05 +08:00
wittam-01
37747177fc feat: auto-grant current user access for bot-created docs, sheets, imports, and uploads (#360)
Change-Id: Idf5b35dbf77d72788895e0a3c34563281d658c88
2026-04-09 18:06:47 +08:00
河伯
9d48ef422b fix(doc): post-process docs +fetch output to improve round-trip fidelity (#214) 2026-04-09 17:57:41 +08:00
tuxedomm
e64d24580a refactor: migrate mail shortcuts to FileIO (#356)
* refactor: migrate mail shortcuts to FileIO

- DraftSnapshot.FIO: inject FileIO into draft snapshot for patch ops
  (addAttachment, loadAndAttachInline, replaceInline)
- emlbuilder.Builder.fio: inject via WithFileIO(), readFile uses FileIO.Open
- mail_draft_edit: loadPatchFile uses runtime.FileIO().Open
- helpers: checkAttachmentSizeLimit takes fio param, uses FileIO.Stat
- validateComposeInlineAndAttachments: pass fio through to size check
- All mail entry points (send/reply/reply_all/forward/draft_create):
  pass runtime.FileIO() to builder and size limit checks
2026-04-09 17:40:30 +08:00
sang-neo03
3db4f42ab8 fix(run): add missing binary check for lark-cli execution (#362) 2026-04-09 17:09:53 +08:00
tuxedomm
0bf4f80ef4 refactor: migrate drive/doc/sheets shortcuts to FileIO (#339)
* refactor: migrate drive/doc/sheets shortcuts to FileIO

- drive_download/upload/import/export: SafeInputPath/SafeOutputPath +
  vfs.Stat/Open/MkdirAll + AtomicWrite → FileIO.Stat/Open/Save
- doc_media_download/insert/upload: same migration pattern
- sheet_export: same migration pattern
- Add Mode() fs.FileMode to fileio.FileInfo for IsRegular() checks
- Add WrapInputStatError helper to preserve error message fidelity
- Add WrapSaveErrorByCategory for standardized save error mapping
2026-04-09 16:34:59 +08:00
feng zhi hao
284e5b6606 feat(mail): add send_as alias support, mailbox/sender discovery APIs, and mail rules API
New capabilities:

  1. Alias (send_as) sending for all compose shortcuts (+send, +reply, +reply-all,
     +forward, +draft-create, +draft-edit):
     - New --mailbox flag separates mailbox routing from sender identity, enabling
       alias sending where --mailbox specifies the owning mailbox and --from
       specifies the alias address in the From header.
     - Example: --mailbox me --from alias@example.com --to bob@example.com
     - --mailbox priority: --mailbox > --from > "me"
     - --from priority: --from > --mailbox > profile("me")

  2. Discovery APIs for available mailboxes and sender addresses:
     - accessible_mailboxes: lists all mailboxes the user can access (primary + shared)
     - send_as: lists available sender addresses for a mailbox (primary, aliases, mailing lists)

  3. Mail rules API:
     - user_mailbox.rules resource: create, delete, list, reorder, update

  4. Reply-all self-exclusion improvement:
     fetchSelfEmailSet now also excludes the --from alias address, preventing the
     sender from appearing in the recipient list when replying via an alias.

  No breaking changes — omitting --mailbox preserves existing behavior.
2026-04-09 14:52:20 +08:00
maochengwei1024-create
af83e5495b fix(config): validate appId and appSecret keychain key consistency (#295)
When config.json is hand-edited, the appId field can become out of sync
with the appSecret keychain reference (e.g. appId changed but
appSecret.id still points to the old app). This causes silent auth
failures at API call time. Add a pre-flight check in
ResolveConfigFromMulti that compares the two before any keychain lookup
or OAPI request, failing fast with actionable guidance.

Change-Id: I74b9ab640642dde3df1ad70890b93b91ee422022
2026-04-09 12:05:24 +08:00
tuxedomm
a3bced3ee5 refactor: migrate base shortcuts to FileIO (#347)
* refactor: migrate base shortcuts to FileIO

- loadJSONInput: SafeInputPath + vfs.ReadFile → fio.Open + io.ReadAll
- parseJSONObject/parseJSONArray/parseJSONValue/parseObjectList/
  parseStringListFlexible: add fio param, pass through to loadJSONInput
- parseStringList: inline comma-split (no longer depends on fio)
- record_upload_attachment: SafeInputPath + vfs.Stat → FileIO.Stat
  with ErrPathValidation check; vfs.Open → FileIO.Open
- All ops files pass runtime.FileIO() to parse helpers
2026-04-09 11:54:58 +08:00
max
35108e1798 feat(vc): extract note doc tokens from calendar event relation API (#333)
* feat(vc): extract meeting_notes and ai_meeting_notes from calendar event relation API

* test(vc): add tests for calendar-to-notes dedup and fallback logic

* fix(vc): address review findings for calendar-to-notes dedup and table output

* refactor(vc): remove ai_meeting_notes concept and simplify dedup logic
2026-04-09 11:20:58 +08:00
tuxedomm
30b97e1bdd chore: add depguard and forbidigo rules to guide FileIO adoption (#342)
- Add depguard linter to block shortcuts/ from importing internal/vfs
  directly (must use runtime.FileIO() instead)
- Add forbidigo rules for os.* filesystem ops, IO streams, os.Exit,
  and filepath.* functions that bypass vfs
- Split os.Remove / os.RemoveAll into separate patterns with accurate
  guidance (RemoveAll not yet in vfs)
- Use compact regex groups for maintainability, no duplicate or
  shadowed patterns

Change-Id: I9e45ab07ca58a61b86bdcea9f1f2cc6181c974bc
2026-04-09 10:56:17 +08:00
liujinkun2025
2715b560b7 add wiki node create shortcut (#320)
Change-Id: I4810fc541c31ae9e3e08539d4b1c91d01f53b7f5
2026-04-09 00:06:57 +08:00
caojie0621
15bd134f5c feat: add sheets +write-image shortcut (#343) 2026-04-08 23:59:39 +08:00
wittam-01
daa21731ad feat: add docs media-preview shortcut (#334)
Change-Id: I5db9e52008e175f975838c8a9c03254afa30f52b
2026-04-08 23:52:24 +08:00
wittam-01
9fab62bf00 feat: add support for additional search filters (#353)
Change-Id: Ib5b06e2df513a835a79a295c45ef1637413afa4e
2026-04-08 23:42:13 +08:00
liangshuo-1
cdd9f9ab49 chore: add missing license headers (#352)
Change-Id: Ic26bedcbb111331eb53d695fccdabd0907a6272f
2026-04-08 23:11:01 +08:00
ygxs
d5d31f0ee4 docs(lark-doc): document advanced boolean and intitle search syntax for AI agents (#210)
Change-Id: I647ffad4579c503711a7ea220c390dca760cd6de
2026-04-08 22:01:10 +08:00
yaozhen00
67cb0a961e ci: add license-header check (#250)
* ci: add license-header check
2026-04-08 21:57:59 +08:00
liangshuo-1
aa4076a7cc docs: add v1.0.6 changelog (#348)
Change-Id: Ic5a1d128c9ec903c0e1a9a673f7da8340e775dc0
2026-04-08 21:57:20 +08:00
171 changed files with 6636 additions and 869 deletions

26
.github/workflows/license-header.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
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,6 +31,7 @@ tests/mail/reports/
/log/
# Generated / test artifacts
internal/registry/meta_data.json
cmd/api/download.bin

View File

@@ -27,6 +27,7 @@ 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:
@@ -45,6 +46,7 @@ linters:
linters:
- bodyclose
- gocritic
- depguard
- forbidigo
- path-except: (shortcuts/|internal/)
linters:
@@ -54,79 +56,56 @@ 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:
# ── 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
# ── 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
msg: >-
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
internal/: use vfs.CreateTemp() or vfs.OpenFile().
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
- pattern: os\.Mkdir(All)?\b
msg: "use vfs.MkdirAll() from internal/vfs"
- pattern: os\.Remove\b
msg: >-
internal/: use vfs.Remove() from internal/vfs.
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
- pattern: os\.RemoveAll\b
msg: >-
internal/: add RemoveAll to internal/vfs/fs.go first, then use vfs.RemoveAll().
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 ──
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 ──
- 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:

16
.licenserc.yaml Normal file
View File

@@ -0,0 +1,16 @@
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,6 +2,69 @@
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
- Improve login scope validation and success output (#317)
- **task**: Support starting pagination from page token (#332)
- Support multipart doc media uploads (#294)
- **mail**: Auto-resolve local image paths in all draft entry points (#205)
- **vc**: Add `+recording` shortcut for `meeting_id` to `minute_token` conversion (#246)
### Bug Fixes
- Resolve concurrency races in RuntimeContext (#330)
- **config**: Save empty config before clearing keychain entries (#291)
- Reject positional arguments in shortcuts (#227)
- Improve raw API diagnostics for invalid or empty JSON responses (#257)
- **docs**: Normalize `board_tokens` in `+create` response for mermaid/whiteboard content (#10)
- **task**: Clarify `--complete` flag help for `get-my-tasks` (#310)
- **help**: Point root help Agent Skills link to README section (#289)
### Documentation
- Clarify `--complete` flag behavior in `get-my-tasks` reference (#308)
### Refactor
- Migrate VC/minutes shortcuts to FileIO (#336)
- Migrate common/client/IM to FileIO and add localfileio tests (#322)
## [v1.0.5] - 2026-04-07
### Features
@@ -193,6 +256,8 @@ 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
[v1.0.3]: https://github.com/larksuite/cli/releases/tag/v1.0.3

View File

@@ -5,7 +5,6 @@ package api
import (
"context"
"encoding/json"
"fmt"
"io"
"regexp"
@@ -44,17 +43,6 @@ 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 {
@@ -88,8 +76,8 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
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(&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")
@@ -118,19 +106,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) {
params, err := parseJsonOpt(opts.Params, "--params")
// 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
}
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
}
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, err
}
if opts.PageSize > 0 {
params["page_size"] = opts.PageSize

View File

@@ -199,6 +199,22 @@ 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,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ package service
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
@@ -148,10 +147,10 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON")
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
}
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")
@@ -310,13 +309,15 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
schemaPath := opts.SchemaPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
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{}{}
// 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
}
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
@@ -365,7 +366,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
}
}
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data)
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
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 JSON format") {
if !strings.Contains(err.Error(), "--params invalid format") {
t.Errorf("unexpected error: %v", err)
}
}
@@ -331,6 +331,24 @@ 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,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package env
import (

View File

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

View File

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

View File

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

View File

@@ -5,35 +5,46 @@ 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) (interface{}, error) {
func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return nil, nil
}
if data == "" {
resolved, err := ResolveInput(data, stdin)
if err != nil {
return nil, output.ErrValidation("--data: %s", err)
}
if resolved == "" {
return nil, nil
}
var body interface{}
if err := json.Unmarshal([]byte(data), &body); err != nil {
if err := json.Unmarshal([]byte(resolved), &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.
func ParseJSONMap(input, label string) (map[string]any, error) {
if input == "" {
// 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 == "" {
return map[string]any{}, nil
}
var result map[string]any
if err := json.Unmarshal([]byte(input), &result); err != nil {
if err := json.Unmarshal([]byte(resolved), &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)
got, err := ParseOptionalBody(tt.method, tt.data, nil)
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)
got, err := ParseJSONMap(tt.input, tt.label, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
return

View File

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

@@ -0,0 +1,189 @@
// 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,6 +240,12 @@ 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,9 +5,21 @@ 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"),
@@ -73,6 +85,85 @@ 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,6 +52,25 @@ 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

@@ -0,0 +1,59 @@
// 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,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,6 @@
// 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.5",
"version": "1.0.7",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

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

View File

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

View File

@@ -1,3 +1,6 @@
// 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 Normal file → Executable file
View File

@@ -1,10 +1,27 @@
#!/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,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require('fs');
const path = require('path');

View File

@@ -5,19 +5,29 @@ package base
import (
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/extension/fileio"
"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(raw string, flagName string) (string, error) {
func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", common.FlagErrorf("--%s cannot be empty", flagName)
@@ -29,11 +39,19 @@ func loadJSONInput(raw string, flagName string) (string, error) {
if path == "" {
return "", common.FlagErrorf("--%s file path cannot be empty after @", flagName)
}
safePath, err := validate.SafeInputPath(path)
if err != nil {
return "", common.FlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, err)
if pc.fio == nil {
return "", common.FlagErrorf("--%s @file inputs require a FileIO provider", flagName)
}
data, err := vfs.ReadFile(safePath)
f, err := pc.fio.Open(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)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return "", common.FlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
}
@@ -86,18 +104,18 @@ func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags
return active[0], nil
}
func parseObjectList(raw string, flagName string) ([]map[string]interface{}, error) {
func parseObjectList(pc *parseCtx, raw string, flagName string) ([]map[string]interface{}, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
var err error
raw, err = loadJSONInput(raw, flagName)
raw, err = loadJSONInput(pc, raw, flagName)
if err != nil {
return nil, err
}
if strings.HasPrefix(raw, "[") {
arr, err := parseJSONArray(raw, flagName)
arr, err := parseJSONArray(pc, raw, flagName)
if err != nil {
return nil, err
}
@@ -111,16 +129,16 @@ func parseObjectList(raw string, flagName string) ([]map[string]interface{}, err
}
return items, nil
}
obj, err := parseJSONObject(raw, flagName)
obj, err := parseJSONObject(pc, raw, flagName)
if err != nil {
return nil, err
}
return []map[string]interface{}{obj}, nil
}
func parseJSONValue(raw string, flagName string) (interface{}, error) {
func parseJSONValue(pc *parseCtx, raw string, flagName string) (interface{}, error) {
var err error
raw, err = loadJSONInput(raw, flagName)
raw, err = loadJSONInput(pc, 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("", "view")
items, err := parseObjectList(testPC, "", "view")
if err != nil || items != nil {
t.Fatalf("items=%v err=%v", items, err)
}
items, err = parseObjectList(`{"name":"grid"}`, "view")
items, err = parseObjectList(testPC, `{"name":"grid"}`, "view")
if err != nil || len(items) != 1 || items[0]["name"] != "grid" {
t.Fatalf("items=%v err=%v", items, err)
}
items, err = parseObjectList(`[{"name":"grid"}]`, "view")
items, err = parseObjectList(testPC, `[{"name":"grid"}]`, "view")
if err != nil || len(items) != 1 || items[0]["name"] != "grid" {
t.Fatalf("items=%v err=%v", items, err)
}
_, err = parseObjectList(`[1]`, "view")
_, err = parseObjectList(testPC, `[1]`, "view")
if err == nil || !strings.Contains(err.Error(), "must be an object") {
t.Fatalf("err=%v", err)
}

View File

@@ -29,6 +29,7 @@ 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
}
@@ -36,7 +37,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
if strings.TrimSpace(raw) == "" {
return nil // 允许无 data_config 的创建(某些类型可先创建后配置)
}
cfg, err := parseJSONObject(raw, "data-config")
cfg, err := parseJSONObject(pc, raw, "data-config")
if err != nil {
return err
}
@@ -50,6 +51,7 @@ 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
@@ -58,7 +60,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
body["type"] = t
}
if raw := runtime.Str("data-config"); raw != "" {
if parsed, err := parseJSONObject(raw, "data-config"); err == nil {
if parsed, err := parseJSONObject(pc, raw, "data-config"); err == nil {
body["data_config"] = parsed
}
}

View File

@@ -29,6 +29,7 @@ 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
}
@@ -36,7 +37,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{
if strings.TrimSpace(raw) == "" {
return nil
}
cfg, err := parseJSONObject(raw, "data-config")
cfg, err := parseJSONObject(pc, raw, "data-config")
if err != nil {
return err
}
@@ -49,12 +50,13 @@ 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(raw, "data-config"); err == nil {
if parsed, err := parseJSONObject(pc, raw, "data-config"); err == nil {
body["data_config"] = parsed
}
}

View File

@@ -95,6 +95,7 @@ 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
@@ -103,7 +104,7 @@ func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContex
body["type"] = blockType
}
if raw := runtime.Str("data-config"); raw != "" {
if parsed, err := parseJSONObject(raw, "data-config"); err == nil {
if parsed, err := parseJSONObject(pc, raw, "data-config"); err == nil {
body["data_config"] = parsed
}
}
@@ -119,12 +120,13 @@ 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(raw, "data-config"); err == nil {
if parsed, err := parseJSONObject(pc, raw, "data-config"); err == nil {
body["data_config"] = parsed
}
}
@@ -240,6 +242,7 @@ 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
@@ -248,7 +251,7 @@ func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
body["type"] = blockType
}
if raw := runtime.Str("data-config"); raw != "" {
parsed, err := parseJSONObject(raw, "data-config")
parsed, err := parseJSONObject(pc, raw, "data-config")
if err != nil {
return err
}
@@ -269,12 +272,13 @@ 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(raw, "data-config")
parsed, err := parseJSONObject(pc, raw, "data-config")
if err != nil {
return err
}

View File

@@ -22,6 +22,10 @@ var BaseFieldCreate = common.Shortcut{
{Name: "json", Desc: "field property JSON object", Required: true},
{Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true},
},
Tips: []string{
`Example: --json '{"name":"Status","type":"text"}'`,
"Agent hint: use the lark-base skill's field-create guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFieldCreate(runtime)
},

View File

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

View File

@@ -23,6 +23,10 @@ var BaseFieldUpdate = common.Shortcut{
{Name: "json", Desc: "field property JSON object", Required: true},
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
},
Tips: []string{
`Example: --json '{"name":"Status","type":"text"}'`,
"Agent hint: use the lark-base skill's field-update guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFieldUpdate(runtime)
},

View File

@@ -29,8 +29,8 @@ type fieldTypeSpec struct {
Extra map[string]interface{}
}
func parseJSONObject(raw string, flagName string) (map[string]interface{}, error) {
resolved, err := loadJSONInput(raw, flagName)
func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]interface{}, error) {
resolved, err := loadJSONInput(pc, raw, flagName)
if err != nil {
return nil, err
}
@@ -41,8 +41,8 @@ func parseJSONObject(raw string, flagName string) (map[string]interface{}, error
return result, nil
}
func parseJSONArray(raw string, flagName string) ([]interface{}, error) {
resolved, err := loadJSONInput(raw, flagName)
func parseJSONArray(pc *parseCtx, raw string, flagName string) ([]interface{}, error) {
resolved, err := loadJSONInput(pc, raw, flagName)
if err != nil {
return nil, err
}
@@ -53,12 +53,12 @@ func parseJSONArray(raw string, flagName string) ([]interface{}, error) {
return result, nil
}
func parseStringListFlexible(raw string, flagName string) ([]string, error) {
func parseStringListFlexible(pc *parseCtx, raw string, flagName string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
resolved, err := loadJSONInput(raw, flagName)
resolved, err := loadJSONInput(pc, raw, flagName)
if err != nil {
return nil, err
}
@@ -82,8 +82,19 @@ func parseStringListFlexible(raw string, flagName string) ([]string, error) {
}
func parseStringList(raw string) []string {
items, _ := parseStringListFlexible(raw, "fields")
return items
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
}
func deepMergeMaps(dst, src map[string]interface{}) map[string]interface{} {

View File

@@ -10,8 +10,12 @@ 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()
@@ -30,36 +34,36 @@ func TestParseHelpers(t *testing.T) {
t.Fatalf("write temp file err=%v", err)
}
_ = tmp.Close()
obj, err := parseJSONObject(`{"name":"demo"}`, "json")
obj, err := parseJSONObject(testPC, `{"name":"demo"}`, "json")
if err != nil || obj["name"] != "demo" {
t.Fatalf("obj=%v err=%v", obj, err)
}
if _, err := parseJSONObject(`[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
t.Fatalf("err=%v", err)
}
obj, err = parseJSONObject("@"+tmp.Name(), "json")
obj, err = parseJSONObject(testPC, "@"+tmp.Name(), "json")
if err != nil || obj["name"] != "from-file" {
t.Fatalf("file obj=%v err=%v", obj, err)
}
arr, err := parseJSONArray(`[1,2]`, "items")
arr, err := parseJSONArray(testPC, `[1,2]`, "items")
if err != nil || len(arr) != 2 {
t.Fatalf("arr=%v err=%v", arr, err)
}
if _, err := parseJSONArray(`{"a":1}`, "items"); err == nil || !strings.Contains(err.Error(), "invalid JSON array") {
if _, err := parseJSONArray(testPC, `{"a":1}`, "items"); err == nil || !strings.Contains(err.Error(), "invalid JSON array") {
t.Fatalf("err=%v", err)
}
list, err := parseStringListFlexible("a, b, ,c", "fields")
list, err := parseStringListFlexible(testPC, "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(`["x","y"]`, "fields")
list, err = parseStringListFlexible(testPC, `["x","y"]`, "fields")
if err != nil || !reflect.DeepEqual(list, []string{"x", "y"}) {
t.Fatalf("list=%v err=%v", list, err)
}
if _, err := parseStringListFlexible(`[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
t.Fatalf("err=%v", err)
}
if _, err := parseJSONValue("{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") {
if _, err := parseJSONValue(testPC, "{", "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"}) {
@@ -262,10 +266,10 @@ func TestFilterAndSortHelpers(t *testing.T) {
}
func TestJSONInputHelpers(t *testing.T) {
if got, err := loadJSONInput(`{"name":"demo"}`, "json"); err != nil || got != `{"name":"demo"}` {
if got, err := loadJSONInput(testPC, `{"name":"demo"}`, "json"); err != nil || got != `{"name":"demo"}` {
t.Fatalf("got=%q err=%v", got, err)
}
if _, err := loadJSONInput("@", "json"); err == nil || !strings.Contains(err.Error(), "file path cannot be empty") {
if _, err := loadJSONInput(testPC, "@", "json"); err == nil || !strings.Contains(err.Error(), "file path cannot be empty") {
t.Fatalf("err=%v", err)
}
tmp := t.TempDir()
@@ -281,7 +285,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("@"+emptyPath, "json"); err == nil || !strings.Contains(err.Error(), "is empty") {
if _, err := loadJSONInput(testPC, "@"+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,7 +35,8 @@ func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.
}
func dryRunRecordUpsert(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, _ := parseJSONObject(runtime.Str("json"), "json")
pc := newParseCtx(runtime)
body, _ := parseJSONObject(pc, 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").
@@ -106,7 +107,8 @@ func executeRecordGet(runtime *common.RuntimeContext) error {
}
func executeRecordUpsert(runtime *common.RuntimeContext) error {
body, err := parseJSONObject(runtime.Str("json"), "json")
pc := newParseCtx(runtime)
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
if err != nil {
return err
}

View File

@@ -14,10 +14,9 @@ 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"
)
@@ -91,15 +90,16 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
filePath := runtime.Str("file")
safeFilePath, err := validate.SafeInputPath(filePath)
if err != nil {
return output.ErrValidation("unsafe file path: %s", err)
fio := runtime.FileIO()
if fio == nil {
return output.ErrValidation("file operations require a FileIO provider")
}
filePath = safeFilePath
fileInfo, err := vfs.Stat(filePath)
fileInfo, err := fio.Stat(filePath)
if err != nil {
return output.ErrValidation("file not found: %s", filePath)
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe file path: %s", err)
}
return output.ErrValidation("file not accessible: %s: %v", filePath, err)
}
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 := vfs.Open(filePath)
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return nil, output.ErrValidation("cannot open file: %v", err)
}

View File

@@ -22,6 +22,10 @@ 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,8 +107,9 @@ 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(runtime.Str("fields"), "fields")
fieldItems, err := parseJSONArray(pc, runtime.Str("fields"), "fields")
if err != nil {
return err
}
@@ -139,7 +140,7 @@ func executeTableCreate(runtime *common.RuntimeContext) error {
result["fields"] = createdFields
}
if tableIDValue != "" && runtime.Str("view") != "" {
viewItems, err := parseObjectList(runtime.Str("view"), "view")
viewItems, err := parseObjectList(pc, runtime.Str("view"), "view")
if err != nil {
return err
}

View File

@@ -21,6 +21,10 @@ var BaseViewCreate = common.Shortcut{
tableRefFlag(true),
{Name: "json", Desc: "view JSON object/array", Required: true},
},
Tips: []string{
`Example: --json '{"name":"Main","type":"grid"}'`,
"Agent hint: use the lark-base skill's view-create guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewCreate(runtime)
},

View File

@@ -35,8 +35,9 @@ 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(runtime.Str("json"), "json")
bodyList, err := parseObjectList(pc, runtime.Str("json"), "json")
if err != nil || len(bodyList) == 0 {
return api.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/views")
}
@@ -57,14 +58,16 @@ func dryRunViewGetProperty(runtime *common.RuntimeContext, segment string) *comm
}
func dryRunViewSetJSONObject(runtime *common.RuntimeContext, segment string) *common.DryRunAPI {
body, _ := parseJSONObject(runtime.Str("json"), "json")
pc := newParseCtx(runtime)
body, _ := parseJSONObject(pc, 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 {
raw, err := parseJSONValue(runtime.Str("json"), "json")
pc := newParseCtx(runtime)
raw, err := parseJSONValue(pc, runtime.Str("json"), "json")
if err != nil {
raw = nil
}
@@ -168,9 +171,10 @@ 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(runtime.Str("json"), "json")
viewItems, err := parseObjectList(pc, runtime.Str("json"), "json")
if err != nil {
return err
}
@@ -211,10 +215,11 @@ 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(runtime.Str("json"), "json")
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
if err != nil {
return err
}
@@ -227,10 +232,11 @@ 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(runtime.Str("json"), "json")
raw, err := parseJSONValue(pc, runtime.Str("json"), "json")
if err != nil {
return err
}

View File

@@ -22,6 +22,10 @@ var BaseViewSetCard = common.Shortcut{
viewRefFlag(true),
{Name: "json", Desc: "card JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"cover_field":"fldCover"}'`,
"Agent hint: use the lark-base skill's view-set-card guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)
},

View File

@@ -22,6 +22,10 @@ var BaseViewSetFilter = common.Shortcut{
viewRefFlag(true),
{Name: "json", Desc: "filter JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"logic":"and","conditions":[["fldStatus","==","Todo"]]}'`,
"Agent hint: use the lark-base skill's view-set-filter guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)
},

View File

@@ -20,7 +20,11 @@ var BaseViewSetGroup = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: "group JSON object/array", Required: true},
{Name: "json", Desc: "group JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"group_config":[{"field":"fldStatus","desc":false}]}'`,
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONValue(runtime)

View File

@@ -22,6 +22,10 @@ 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,6 +22,10 @@ var BaseViewSetTimebar = common.Shortcut{
viewRefFlag(true),
{Name: "json", Desc: "timebar JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"start_time":"fldStart","end_time":"fldEnd","title":"fldTitle"}'`,
"Agent hint: use the lark-base skill's view-set-timebar guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)
},

View File

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

View File

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

View File

@@ -5,8 +5,6 @@ package common
import (
"fmt"
"os"
"path/filepath"
"strconv"
"testing"
"time"
@@ -57,32 +55,3 @@ 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,8 +11,6 @@ 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"
@@ -51,13 +49,9 @@ type DriveMediaMultipartUploadConfig struct {
}
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
safeFilePath, err := validate.SafeInputPath(cfg.FilePath)
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return "", output.ErrValidation("invalid file path: %s", err)
}
f, err := vfs.Open(safeFilePath)
if err != nil {
return "", output.ErrValidation("cannot read file: %s", err)
return "", WrapInputStatError(err)
}
defer f.Close()
@@ -173,13 +167,9 @@ func ExtractDriveMediaUploadFileToken(data map[string]interface{}, action string
}
func uploadDriveMediaMultipartParts(runtime *RuntimeContext, filePath string, fileSize int64, session DriveMediaMultipartUploadSession) error {
safeFilePath, err := validate.SafeInputPath(filePath)
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return output.ErrValidation("invalid file path: %s", err)
}
f, err := vfs.Open(safeFilePath)
if err != nil {
return output.ErrValidation("cannot read file: %s", err)
return WrapInputStatError(err)
}
defer f.Close()

View File

@@ -5,14 +5,9 @@ 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.
@@ -37,16 +32,3 @@ 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

@@ -0,0 +1,137 @@
// 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,6 +363,26 @@ 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,13 +5,11 @@ 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).
@@ -88,40 +86,11 @@ 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).
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
// 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
}
// RejectDangerousChars returns an error if value contains ASCII control

View File

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

View File

@@ -7,28 +7,15 @@ 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",
@@ -66,8 +53,7 @@ var DocMediaDownload = common.Shortcut{
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 := validate.SafeOutputPath(outputPath); err != nil {
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
@@ -91,40 +77,41 @@ var DocMediaDownload = common.Shortcut{
}
defer resp.Body.Close()
// Auto-detect extension from Content-Type
finalPath := outputPath
currentExt := filepath.Ext(outputPath)
if currentExt == "" {
contentType := resp.Header.Get("Content-Type")
mimeType := strings.Split(contentType, ";")[0]
mimeType = strings.TrimSpace(mimeType)
if ext, ok := mimeToExt[mimeType]; ok {
finalPath = outputPath + ext
} else if mediaType == "whiteboard" {
finalPath = outputPath + ".png"
fallbackExt := ""
if mediaType == "whiteboard" {
fallbackExt = ".png"
}
finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, fallbackExt)
// Validate final path after extension append
if finalPath != outputPath {
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
}
safePath, err := validate.SafeOutputPath(finalPath)
// 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 output.ErrValidation("unsafe output path: %s", err)
}
if err := common.EnsureWritableFile(safePath, overwrite); err != nil {
return err
return common.WrapSaveErrorByCategory(err, "io")
}
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return output.Errorf(output.ExitInternal, "io", "cannot create parent directory: %v", err)
savedPath, _ := runtime.ResolveSavePath(finalPath)
if savedPath == "" {
savedPath = finalPath
}
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": safePath,
"size_bytes": sizeBytes,
"saved_path": savedPath,
"size_bytes": result.Size(),
"content_type": resp.Header.Get("Content-Type"),
}, nil)
return nil

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"mime"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
type docMediaExtensionResolution struct {
Ext string
Source string
Detail string
}
var docMediaMimeToExt = map[string]string{
"application/msword": ".doc",
"application/pdf": ".pdf",
"application/vnd.ms-excel": ".xls",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/xml": ".xml",
"application/zip": ".zip",
"image/bmp": ".bmp",
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/svg+xml": ".svg",
"image/webp": ".webp",
"text/csv": ".csv",
"text/html": ".html",
"text/plain": ".txt",
"text/xml": ".xml",
"video/mp4": ".mp4",
}
func autoAppendDocMediaExtension(outputPath string, header http.Header, fallbackExt string) (string, *docMediaExtensionResolution) {
if docMediaHasExplicitExtension(outputPath) {
return outputPath, nil
}
normalizedPath := outputPath
if filepath.Ext(outputPath) == "." {
normalizedPath = strings.TrimSuffix(outputPath, ".")
}
if resolution := docMediaExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if resolution := docMediaExtensionByContentDisposition(header); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if fallbackExt != "" {
return normalizedPath + fallbackExt, &docMediaExtensionResolution{
Ext: fallbackExt,
Source: "fallback",
Detail: "default fallback",
}
}
return outputPath, nil
}
func docMediaHasExplicitExtension(path string) bool {
ext := filepath.Ext(path)
return ext != "" && ext != "."
}
func docMediaExtensionByContentType(contentType string) *docMediaExtensionResolution {
if contentType == "" {
return nil
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
}
if ext, ok := docMediaMimeToExt[strings.ToLower(mediaType)]; ok {
return &docMediaExtensionResolution{
Ext: ext,
Source: "Content-Type",
Detail: contentType,
}
}
return nil
}
func docMediaExtensionByContentDisposition(header http.Header) *docMediaExtensionResolution {
filename := strings.TrimSpace(larkcore.FileNameByHeader(header))
if filename == "" {
return nil
}
ext := filepath.Ext(filename)
if ext == "" || ext == "." {
return nil
}
return &docMediaExtensionResolution{
Ext: ext,
Source: "Content-Disposition",
Detail: filename,
}
}

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, filePath, parentType, stepBase+2)
appendDocMediaInsertUploadDryRun(d, runtime.FileIO(), 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,20 +93,15 @@ 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 := vfs.Stat(safeFilePath)
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return output.ErrValidation("file not found: %s", filePath)
return common.WrapInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
@@ -347,12 +342,12 @@ func extractCreatedBlockTargets(createData map[string]interface{}, mediaType str
return blockID, uploadParentNode, replaceBlockID
}
func appendDocMediaInsertUploadDryRun(d *common.DryRunAPI, filePath, parentType string, step int) {
func appendDocMediaInsertUploadDryRun(d *common.DryRunAPI, fio fileio.FileIO, 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(filePath) {
if docMediaShouldUseMultipart(fio, filePath) {
d.POST("/open-apis/drive/v1/medias/upload_prepare").
Desc(fmt.Sprintf("[%da] Initialize multipart upload", step)).
Body(map[string]interface{}{

View File

@@ -0,0 +1,104 @@
// 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,6 +18,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -285,15 +286,289 @@ 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"`
Body map[string]interface{} `json:"body"`
Desc string `json:"desc"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
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()
@@ -323,3 +598,23 @@ func decodeDocDryRun(t *testing.T, dryAPI *common.DryRunAPI) docDryRunOutput {
}
return dry
}
func decodeDocCommandOutput(t *testing.T, stdout *bytes.Buffer) docCommandOutput {
t.Helper()
var out docCommandOutput
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("decode command output: %v; output=%s", err, stdout.String())
}
return out
}
func mustDocSafeOutputPath(t *testing.T, output string) string {
t.Helper()
path, err := validate.SafeOutputPath(output)
if err != nil {
t.Fatalf("SafeOutputPath(%q) error: %v", output, err)
}
return path
}

View File

@@ -8,9 +8,8 @@ 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"
)
@@ -41,7 +40,7 @@ var MediaUpload = common.Shortcut{
body["extra"] = fmt.Sprintf(`{"drive_route_token":"%s"}`, docId)
}
dry := common.NewDryRunAPI()
if docMediaShouldUseMultipart(filePath) {
if docMediaShouldUseMultipart(runtime.FileIO(), filePath) {
prepareBody := map[string]interface{}{
"file_name": filepath.Base(filePath),
"parent_type": parentType,
@@ -81,15 +80,10 @@ 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 := vfs.Stat(safeFilePath)
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return output.ErrValidation("file not found: %s", filePath)
return common.WrapInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
@@ -147,14 +141,10 @@ func uploadDocMediaFile(runtime *common.RuntimeContext, filePath, fileName strin
})
}
func docMediaShouldUseMultipart(filePath string) bool {
func docMediaShouldUseMultipart(fio fileio.FileIO, 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.
safeFilePath, err := validate.SafeInputPath(filePath)
if err != nil {
return false
}
info, err := vfs.Stat(safeFilePath)
info, err := fio.Stat(filePath)
if err != nil {
return false
}

View File

@@ -5,6 +5,7 @@ package doc
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -40,51 +41,90 @@ var DocsCreate = common.Shortcut{
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
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().
args := buildDocsCreateArgs(runtime)
d := 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 := 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
}
args := buildDocsCreateArgs(runtime)
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

@@ -0,0 +1,240 @@
// 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,6 +61,10 @@ 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,15 +168,48 @@ func buildDocsSearchRequest(query, filterStr, pageToken, pageSizeStr string) (ma
return nil, err
}
requestData["doc_filter"] = filter
wikiFilter := make(map[string]interface{}, len(filter))
for k, v := range filter {
wikiFilter[k] = v
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["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,3 +100,114 @@ 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

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

@@ -0,0 +1,333 @@
// 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,6 +13,7 @@ func Shortcuts() []common.Shortcut {
DocsFetch,
DocsUpdate,
DocMediaInsert,
DocMediaPreview,
DocMediaDownload,
}
}

View File

@@ -7,13 +7,12 @@ 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"
)
@@ -51,12 +50,13 @@ var DriveDownload = common.Shortcut{
if outputPath == "" {
outputPath = fileToken
}
safePath, err := validate.SafeOutputPath(outputPath)
if err != nil {
return output.ErrValidation("unsafe output path: %s", err)
// Early path validation + overwrite check
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
return output.ErrValidation("unsafe output path: %s", resolveErr)
}
if err := common.EnsureWritableFile(safePath, overwrite); err != nil {
return err
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken))
@@ -70,18 +70,21 @@ var DriveDownload = common.Shortcut{
}
defer resp.Body.Close()
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err)
}
sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600)
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err)
return common.WrapSaveErrorByCategory(err, "io")
}
savedPath, _ := runtime.ResolveSavePath(outputPath)
if savedPath == "" {
savedPath = outputPath
}
runtime.Out(map[string]interface{}{
"saved_path": safePath,
"size_bytes": sizeBytes,
"saved_path": savedPath,
"size_bytes": result.Size(),
}, 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(outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
if err != nil {
return err
}

View File

@@ -4,6 +4,7 @@
package drive
import (
"bytes"
"context"
"fmt"
"net/http"
@@ -14,10 +15,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"
)
@@ -252,8 +253,8 @@ func fetchDriveMetaTitle(runtime *common.RuntimeContext, token, docType string)
}
// saveContentToOutputDir validates the target path, enforces overwrite policy,
// and writes the payload atomically to disk.
func saveContentToOutputDir(outputDir, fileName string, payload []byte, overwrite bool) (string, error) {
// and writes the payload atomically via FileIO.Save.
func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, payload []byte, overwrite bool) (string, error) {
if outputDir == "" {
outputDir = "."
}
@@ -262,21 +263,22 @@ func saveContentToOutputDir(outputDir, fileName string, payload []byte, overwrit
// names cannot escape the requested output directory.
safeName := sanitizeExportFileName(fileName, "export.bin")
target := filepath.Join(outputDir, safeName)
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
// 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)
}
}
if err := vfs.MkdirAll(filepath.Dir(safePath), 0755); err != nil {
return "", output.Errorf(output.ExitInternal, "io", "cannot create output directory: %s", err)
if _, err := fio.Save(target, fileio.SaveOptions{}, bytes.NewReader(payload)); err != nil {
return "", common.WrapSaveErrorByCategory(err, "io")
}
if err := validate.AtomicWrite(safePath, payload, 0644); err != nil {
return "", output.Errorf(output.ExitInternal, "io", "cannot write file: %s", err)
resolvedPath, _ := fio.ResolvePath(target)
if resolvedPath == "" {
resolvedPath = target
}
return safePath, nil
return resolvedPath, nil
}
// downloadDriveExportFile downloads the exported artifact, derives a safe local
@@ -303,7 +305,7 @@ func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext
// request an explicit local file name.
fileName = client.ResolveFilename(apiResp)
}
savedPath, err := saveContentToOutputDir(outputDir, fileName, apiResp.RawBody, overwrite)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, apiResp.RawBody, overwrite)
if err != nil {
return nil, err
}

View File

@@ -15,6 +15,7 @@ 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) {
@@ -465,7 +466,8 @@ func TestSaveContentToOutputDirRejectsOverwriteWithoutFlag(t *testing.T) {
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
_, err = saveContentToOutputDir(".", "exists.txt", []byte("new"), false)
fio := &localfileio.LocalFileIO{}
_, err = saveContentToOutputDir(fio, ".", "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,10 +9,8 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -49,7 +47,7 @@ var DriveImport = common.Shortcut{
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
}
fileSize, err := preflightDriveImportFile(&spec)
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
@@ -66,6 +64,9 @@ 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
},
@@ -76,7 +77,7 @@ var DriveImport = common.Shortcut{
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
}
if _, err := preflightDriveImportFile(&spec); err != nil {
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
@@ -133,23 +134,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(spec *driveImportSpec) (int64, error) {
func preflightDriveImportFile(fio fileio.FileIO, 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.
safeFilePath, err := validate.SafeInputPath(spec.FilePath)
info, err := fio.Stat(spec.FilePath)
if err != nil {
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)
return 0, common.WrapInputStatError(err)
}
if !info.Mode().IsRegular() {
return 0, output.ErrValidation("file must be a regular file: %s", spec.FilePath)

View File

@@ -11,8 +11,6 @@ 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"
@@ -85,9 +83,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 := vfs.Stat(filePath)
importInfo, err := runtime.FileIO().Stat(filePath)
if err != nil {
return "", output.ErrValidation("cannot read file: %s", err)
return "", common.WrapInputStatError(err)
}
fileSize := importInfo.Size()

View File

@@ -196,6 +196,9 @@ 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,6 +12,7 @@ import (
"github.com/spf13/cobra"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/larksuite/cli/shortcuts/common"
)

View File

@@ -0,0 +1,244 @@
// 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 = queryImportTask(runtime, ticket)
result, err = queryImportTaskAndAutoGrantPermission(runtime, ticket)
case "export":
result, err = queryExportTask(runtime, ticket, fileToken)
case "task_check":
@@ -127,14 +127,16 @@ var DriveTaskResult = common.Shortcut{
},
}
// queryImportTask returns a stable, shortcut-friendly view of the import task.
func queryImportTask(runtime *common.RuntimeContext, ticket string) (map[string]interface{}, error) {
// 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) {
status, err := getDriveImportStatus(runtime, ticket)
if err != nil {
return nil, err
}
return map[string]interface{}{
result := map[string]interface{}{
"scenario": "import",
"ticket": status.Ticket,
"type": status.DocType,
@@ -146,7 +148,13 @@ func queryImportTask(runtime *common.RuntimeContext, ticket string) (map[string]
"token": status.Token,
"url": status.URL,
"extra": status.Extra,
}, nil
}
if status.Ready() {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, status.Token, status.DocType); grant != nil {
result["permission_grant"] = grant
}
}
return result, nil
}
// queryExportTask returns the export task status together with download metadata

View File

@@ -156,6 +156,64 @@ 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