Compare commits

..

22 Commits

Author SHA1 Message Date
xiaodiyin
896e72d50d fix(install): harden checksum verification and module loading 2026-04-09 23:05:10 +08:00
xiaodiyin
066e7011a1 fix(security): verify release checksum and harden download path 2026-04-09 20:14:54 +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
161 changed files with 6605 additions and 886 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

View File

@@ -45,6 +45,32 @@ jobs:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Fetch checksums.txt from GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release download "${GITHUB_REF_NAME}" \
--repo "${GITHUB_REPOSITORY}" \
--pattern checksums.txt \
--dir .
- name: Verify checksums.txt is present and matches current version
run: |
set -euo pipefail
test -s checksums.txt
VERSION="${GITHUB_REF_NAME#v}"
for plat in \
"linux-amd64.tar.gz" \
"linux-arm64.tar.gz" \
"darwin-amd64.tar.gz" \
"darwin-arm64.tar.gz" \
"windows-amd64.zip" \
"windows-arm64.zip"
do
grep -qE '^[0-9a-fA-F]{64}[[:space:]]+\*?lark-cli-'"${VERSION}"'-'"${plat}"'$' checksums.txt \
|| { echo "checksums.txt missing valid entry for lark-cli-${VERSION}-${plat}"; exit 1; }
done
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

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

@@ -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

@@ -28,6 +28,7 @@
"files": [
"scripts/install.js",
"scripts/run.js",
"checksums.txt",
"CHANGELOG.md"
]
}

View File

@@ -1,7 +1,15 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const { execFileSync } = require("child_process");
const os = require("os");
const crypto = require("crypto");
class ChecksumError extends Error {}
class NetworkError extends Error {}
class PackageIntegrityError extends Error {}
const VERSION = require("../package.json").version;
const REPO = "larksuite/cli";
@@ -18,78 +26,266 @@ const ARCH_MAP = {
arm64: "arm64",
};
const platform = PLATFORM_MAP[process.platform];
const arch = ARCH_MAP[process.arch];
if (!platform || !arch) {
console.error(
`Unsupported platform: ${process.platform}-${process.arch}`
);
process.exit(1);
}
const isWindows = process.platform === "win32";
const ext = isWindows ? ".zip" : ".tar.gz";
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`;
const ALLOWED_INITIAL_HOSTS = new Set([
"github.com",
"registry.npmmirror.com",
]);
const CURL_CONNECT_TIMEOUT_SEC = 10;
const CURL_MAX_TIME_SEC = 120;
const CURL_MAX_REDIRS = 5;
const DEFAULT_CHECKSUM_PATH = path.join(__dirname, "..", "checksums.txt");
// Defensive: escape single quotes for PowerShell literal-string embedding.
// tmpDir comes from mkdtempSync so is controlled, but this hardens against
// future refactors that route external input into the script.
function escapeSingleQuotes(s) {
return s.replace(/'/g, "''");
}
const binDir = path.join(__dirname, "..", "bin");
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
fs.mkdirSync(binDir, { recursive: true });
function download(url, destPath) {
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
// errors when the certificate revocation list server is unreachable
const sslFlag = isWindows ? "--ssl-revoke-best-effort " : "";
execSync(
`curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`,
{ stdio: ["ignore", "ignore", "pipe"] }
);
// JS-layer pre-check: initial URL must be https and in allowlist.
// Redirect targets are NOT host-checked; we rely on curl's
// --proto-redir =https + --max-redirs + SHA256 verify for safety.
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
throw new NetworkError(`Non-HTTPS URL rejected: ${url}`);
}
if (!ALLOWED_INITIAL_HOSTS.has(parsed.hostname)) {
throw new NetworkError(`Untrusted initial host: ${parsed.hostname}`);
}
const args = [
"--fail", // HTTP 4xx/5xx -> non-zero exit
"--location", // follow redirects
"--proto", "=https", // initial URL: https only
"--proto-redir", "=https", // redirect targets: https only
"--max-redirs", String(CURL_MAX_REDIRS),
"--tlsv1.2", // minimum TLS 1.2
"--connect-timeout", String(CURL_CONNECT_TIMEOUT_SEC),
"--max-time", String(CURL_MAX_TIME_SEC),
"--silent", "--show-error",
"--output", destPath,
];
if (isWindows) {
// Schannel CRL check hard-fails when the CRL server is unreachable;
// this flag was in the original install.js and is preserved to
// avoid regression for users in corporate networks.
args.unshift("--ssl-revoke-best-effort");
}
// URL is always the last positional arg.
args.push(url);
try {
execFileSync("curl", args, {
stdio: ["ignore", "ignore", "pipe"],
});
} catch (err) {
if (err.code === "ENOENT") {
// ENOENT is NOT a NetworkError: another source won't help (curl
// is missing). Throw plain Error so the fallback loop re-raises
// instead of silently trying the next URL.
throw new Error(
"curl is required for installation but was not found in PATH. " +
"Install curl or manually download the binary from " +
`https://github.com/${REPO}/releases/tag/v${VERSION}`
);
}
const stderr = err.stderr ? err.stderr.toString().trim() : "";
const exitCode = err.status != null ? err.status : "unknown";
throw new NetworkError(
`curl exited with code ${exitCode}${stderr ? ": " + stderr : ""}`
);
}
}
function install() {
function downloadWithFallback(urls, destPath) {
const attempts = [];
for (const url of urls) {
try {
download(url, destPath);
return url;
} catch (err) {
if (err instanceof NetworkError) {
attempts.push({ url, error: err.message });
continue;
}
// ChecksumError, plain Error (ENOENT), or any other type:
// re-raise immediately without trying the next source.
throw err;
}
}
const detail = attempts
.map((a) => ` - ${a.url}\n ${a.error}`)
.join("\n");
throw new NetworkError(`All download sources failed:\n${detail}`);
}
function extract(archivePath, tmpDir) {
if (isWindows) {
const script =
`$ErrorActionPreference = 'Stop'\n` +
`Expand-Archive -LiteralPath '${escapeSingleQuotes(archivePath)}' ` +
`-DestinationPath '${escapeSingleQuotes(tmpDir)}' -Force\n`;
const scriptPath = path.join(tmpDir, "extract.ps1");
fs.writeFileSync(scriptPath, script, { encoding: "utf-8" });
execFileSync("powershell", [
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy", "Bypass",
"-File", scriptPath,
], { stdio: "ignore" });
} else {
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
stdio: "ignore",
});
}
}
function verifyChecksum(filePath, expectedHash) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash("sha256");
const stream = fs.createReadStream(filePath);
stream.on("error", reject);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => {
const actual = hash.digest("hex");
const expected = expectedHash.toLowerCase();
if (actual !== expected) {
reject(new ChecksumError(
`SHA256 mismatch for ${path.basename(filePath)}\n` +
` expected: ${expected}\n` +
` actual: ${actual}`
));
return;
}
resolve();
});
});
}
function getExpectedChecksum(archiveFilename, checksumPath = DEFAULT_CHECKSUM_PATH) {
if (!fs.existsSync(checksumPath)) {
// Packaging bug, not a tamper signal — routed separately.
throw new PackageIntegrityError("checksums.txt missing from package");
}
const contents = fs.readFileSync(checksumPath, "utf-8");
const lineRegex = /^([0-9a-fA-F]{64})\s+\*?(.+)$/;
for (const rawLine of contents.split("\n")) {
const line = rawLine.trim();
if (line === "" || line.startsWith("#")) continue;
const match = line.match(lineRegex);
if (!match) continue;
const [, hash, filename] = match;
if (filename.trim() === archiveFilename) {
return hash.toLowerCase();
}
}
throw new ChecksumError(`No checksum entry for ${archiveFilename}`);
}
async function install() {
const platform = PLATFORM_MAP[process.platform];
const arch = ARCH_MAP[process.arch];
if (!platform || !arch) {
throw new Error(
`Unsupported platform: ${process.platform}-${process.arch}. ` +
`Download manually from ` +
`https://github.com/${REPO}/releases/tag/v${VERSION}`
);
}
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
const sources = [
`https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`,
`https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`,
];
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
const archivePath = path.join(tmpDir, archiveName);
try {
try {
download(GITHUB_URL, archivePath);
} catch (err) {
download(MIRROR_URL, archivePath);
}
// 1. Early fail: if the bundled checksums.txt is broken,
// report now before spending bandwidth.
const expectedHash = getExpectedChecksum(archiveName);
if (isWindows) {
execSync(
`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`,
{ stdio: "ignore" }
);
} else {
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, {
stdio: "ignore",
});
}
// 2. Multi-source download; only NetworkError triggers fallback.
const sourceUrl = downloadWithFallback(sources, archivePath);
// 3. Integrity check outside the fallback loop. Mismatch aborts
// the entire install, does NOT try the next source.
await verifyChecksum(archivePath, expectedHash);
// 4. Extract (safe: bytes match the official release).
extract(archivePath, tmpDir);
// 5. Copy binary into place and chmod.
const binaryName = NAME + (isWindows ? ".exe" : "");
const extractedBinary = path.join(tmpDir, binaryName);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(extractedBinary, dest);
fs.chmodSync(dest, 0o755);
console.log(`${NAME} v${VERSION} installed successfully`);
console.log(
`${NAME} v${VERSION} installed successfully ` +
`(from ${new URL(sourceUrl).hostname})`
);
} finally {
// 6. Always clean up the temp directory.
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
try {
install();
} catch (err) {
console.error(`Failed to install ${NAME}:`, err.message);
console.error(
`\nIf you are behind a firewall or in a restricted network, try setting a proxy:\n` +
` export https_proxy=http://your-proxy:port\n` +
` npm install -g @larksuite/cli`
);
process.exit(1);
if (require.main === module) {
install().catch((err) => {
if (err instanceof PackageIntegrityError) {
console.error(`\n${NAME} install aborted: the installed package looks broken.\n`);
console.error(err.message);
console.error(
`\nRe-install the package; if the issue persists, please report it:\n` +
` https://github.com/${REPO}/issues\n`
);
} else if (err instanceof ChecksumError) {
console.error(`\n[SECURITY] ${NAME} install aborted due to integrity check failure:\n`);
console.error(err.message);
console.error(
`\nRetry the install; if it persists, report it and download manually:\n` +
` https://github.com/${REPO}/releases/tag/v${VERSION}\n`
);
} else if (err instanceof NetworkError) {
console.error(`\n${NAME} install failed due to network errors:\n`);
console.error(err.message);
console.error(
`\nIf you are behind a firewall or on a restricted network, try configuring a proxy:\n` +
` export https_proxy=http://your-proxy:port\n` +
` npm install -g @larksuite/cli\n`
);
} else {
console.error(`\n${NAME} install failed:\n${err.stack || err.message}`);
}
process.exit(1);
});
}
module.exports = {
verifyChecksum,
getExpectedChecksum,
ChecksumError,
NetworkError,
PackageIntegrityError,
};

103
scripts/install.test.js Normal file
View File

@@ -0,0 +1,103 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const { test } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const crypto = require("crypto");
const {
verifyChecksum,
getExpectedChecksum,
ChecksumError,
PackageIntegrityError,
} = require("./install.js");
function mktmpdir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "install-test-"));
}
test("verifyChecksum: correct hash resolves", async () => {
const dir = mktmpdir();
try {
const filePath = path.join(dir, "data.bin");
const bytes = Buffer.from("hello world");
fs.writeFileSync(filePath, bytes);
const correctHash = crypto.createHash("sha256").update(bytes).digest("hex");
await verifyChecksum(filePath, correctHash);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("verifyChecksum: mismatched hash throws ChecksumError", async () => {
const dir = mktmpdir();
try {
const filePath = path.join(dir, "data.bin");
fs.writeFileSync(filePath, "hello world");
const wrongHash = "0".repeat(64);
await assert.rejects(
() => verifyChecksum(filePath, wrongHash),
(err) => err instanceof ChecksumError,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("getExpectedChecksum: returns hash for listed archive", () => {
const dir = mktmpdir();
try {
const checksumsPath = path.join(dir, "checksums.txt");
const knownHash = "a".repeat(64);
fs.writeFileSync(
checksumsPath,
`${knownHash} lark-cli-1.0.0-linux-amd64.tar.gz\n`
);
const result = getExpectedChecksum(
"lark-cli-1.0.0-linux-amd64.tar.gz",
checksumsPath,
);
assert.strictEqual(result, knownHash);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("getExpectedChecksum: throws PackageIntegrityError (not ChecksumError) when checksums.txt file is absent", () => {
const dir = mktmpdir();
try {
const missingPath = path.join(dir, "does-not-exist.txt");
assert.throws(
() => getExpectedChecksum("lark-cli-1.0.0-linux-amd64.tar.gz", missingPath),
(err) =>
err instanceof PackageIntegrityError &&
!(err instanceof ChecksumError),
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("getExpectedChecksum: throws ChecksumError when entry missing", () => {
const dir = mktmpdir();
try {
const checksumsPath = path.join(dir, "checksums.txt");
fs.writeFileSync(
checksumsPath,
`${"a".repeat(64)} some-other-archive.tar.gz\n`
);
assert.throws(
() => getExpectedChecksum("nonexistent-archive.tar.gz", checksumsPath),
(err) => err instanceof ChecksumError,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -12,9 +12,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/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -66,8 +66,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)
}
@@ -105,26 +104,35 @@ var DocMediaDownload = common.Shortcut{
}
}
safePath, err := validate.SafeOutputPath(finalPath)
// 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 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

@@ -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,126 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
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/shortcuts/common"
)
var previewMimeToExt = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
"application/pdf": ".pdf",
"video/mp4": ".mp4",
"text/plain": ".txt",
}
const PreviewType_SOURCE_FILE = "16"
var DocMediaPreview = common.Shortcut{
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 := outputPath
currentExt := filepath.Ext(outputPath)
if currentExt == "" {
contentType := resp.Header.Get("Content-Type")
mimeType := strings.Split(contentType, ";")[0]
mimeType = strings.TrimSpace(mimeType)
if ext, ok := previewMimeToExt[mimeType]; ok {
finalPath = outputPath + ext
}
}
// 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

@@ -285,12 +285,99 @@ func TestDocMediaDownloadRejectsHTTPErrorBeforeWrite(t *testing.T) {
}
}
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)
}
}
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"`
}

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) {

View File

@@ -15,8 +15,6 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -40,7 +38,7 @@ var DriveUpload = common.Shortcut{
if fileName == "" {
fileName = filepath.Base(filePath)
}
return common.NewDryRunAPI().
d := common.NewDryRunAPI().
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload)").
POST("/open-apis/drive/v1/files/upload_all").
Body(map[string]interface{}{
@@ -49,26 +47,24 @@ var DriveUpload = common.Shortcut{
"parent_node": folderToken,
"file": "@" + filePath,
})
if runtime.IsBot() {
d.Desc("After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
filePath := runtime.Str("file")
folderToken := runtime.Str("folder-token")
name := runtime.Str("name")
safeFilePath, err := validate.SafeInputPath(filePath)
if err != nil {
return output.ErrValidation("unsafe file path: %s", err)
}
filePath = safeFilePath
fileName := name
if fileName == "" {
fileName = filepath.Base(filePath)
}
info, err := vfs.Stat(filePath)
info, err := runtime.FileIO().Stat(filePath)
if err != nil {
return output.ErrValidation("cannot read file: %s", err)
return common.WrapInputStatError(err)
}
fileSize := info.Size()
@@ -85,19 +81,24 @@ var DriveUpload = common.Shortcut{
return err
}
runtime.Out(map[string]interface{}{
out := map[string]interface{}{
"file_token": fileToken,
"file_name": fileName,
"size": fileSize,
}, nil)
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, fileToken, "file"); grant != nil {
out["permission_grant"] = grant
}
runtime.Out(out, nil)
return nil
},
}
func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, folderToken string, fileSize int64) (string, error) {
f, err := vfs.Open(filePath)
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", err
return "", common.WrapInputStatError(err)
}
defer f.Close()
@@ -180,20 +181,16 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
partSize = remaining
}
partFile, err := vfs.Open(filePath)
partFile, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", output.ErrValidation("cannot open file: %v", err)
}
if _, err := partFile.Seek(offset, io.SeekStart); err != nil {
partFile.Close()
return "", output.Errorf(output.ExitInternal, "internal_error", "seek to block %d failed: %v", seq, err)
return "", common.WrapInputStatError(err)
}
fd := larkcore.NewFormdata()
fd.AddField("upload_id", uploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", partSize))
fd.AddFile("file", io.LimitReader(partFile, partSize))
fd.AddFile("file", io.NewSectionReader(partFile, offset, partSize))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,

View File

@@ -11,7 +11,7 @@ func TestAcceptanceReplyDraftSubjectOnly(t *testing.T) {
originalInline := findPart(snapshot.Body, "1.2")
originalAttachment := findPart(snapshot.Body, "1.3")
if err := Apply(snapshot, Patch{
if err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_subject", Value: "Reply updated"}},
}); err != nil {
t.Fatalf("Apply() error = %v", err)
@@ -46,7 +46,7 @@ func TestAcceptanceReplyDraftSubjectOnly(t *testing.T) {
func TestAcceptanceHTMLInlineReplaceHTML(t *testing.T) {
snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/html_inline_draft.eml"))
if err := Apply(snapshot, Patch{
if err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/html", Selector: "primary", Value: `<div>updated<img src="cid:logo"></div>`}},
}); err != nil {
t.Fatalf("Apply() error = %v", err)
@@ -70,7 +70,7 @@ func TestAcceptanceHTMLInlineReplaceHTML(t *testing.T) {
func TestAcceptanceAlternativeSetBodyUpdatesHTMLAndSummary(t *testing.T) {
snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/alternative_draft.eml"))
if err := Apply(snapshot, Patch{
if err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: "<div>updated <strong>body</strong></div>"}},
}); err != nil {
t.Fatalf("Apply() error = %v", err)
@@ -97,7 +97,7 @@ func TestAcceptanceCalendarDraftAppendPlainPreservesCalendar(t *testing.T) {
if originalCalendar == nil {
t.Fatalf("calendar part missing")
}
if err := Apply(snapshot, Patch{
if err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: "\nagenda"}},
}); err != nil {
t.Fatalf("Apply() error = %v", err)
@@ -122,7 +122,7 @@ func TestAcceptanceCalendarDraftAppendPlainPreservesCalendar(t *testing.T) {
func TestAcceptanceSignedDraftSubjectOnlyPreservesSignedEntity(t *testing.T) {
snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/multipart_signed_draft.eml"))
originalBodyEntity := string(snapshot.Body.RawEntity)
if err := Apply(snapshot, Patch{
if err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_subject", Value: "Signed updated"}},
}); err != nil {
t.Fatalf("Apply() error = %v", err)
@@ -144,7 +144,7 @@ func TestAcceptanceDirtyMultipartAppendPlainPreservesOuterNoise(t *testing.T) {
snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/dirty_multipart_preamble.eml"))
originalPreamble := string(snapshot.Body.Preamble)
originalEpilogue := string(snapshot.Body.Epilogue)
if err := Apply(snapshot, Patch{
if err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: "\nworld"}},
}); err != nil {
t.Fatalf("Apply() error = %v", err)

View File

@@ -9,6 +9,8 @@ import (
"mime"
"net/mail"
"strings"
"github.com/larksuite/cli/extension/fileio"
)
type DraftRaw struct {
@@ -98,6 +100,12 @@ func (p *Part) FileName() string {
return ""
}
// DraftCtx carries runtime dependencies for draft operations.
// It is separate from DraftSnapshot to keep the snapshot a pure data model.
type DraftCtx struct {
FIO fileio.FileIO
}
type DraftSnapshot struct {
DraftID string
Headers []Header

View File

@@ -5,6 +5,7 @@ package draft
import (
"fmt"
"io"
"mime"
"path/filepath"
"regexp"
@@ -12,7 +13,6 @@ import (
"github.com/google/uuid"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/mail/filecheck"
)
@@ -39,26 +39,26 @@ var bodyChangingOps = map[string]bool{
"append_body": true,
}
func Apply(snapshot *DraftSnapshot, patch Patch) error {
func Apply(dctx *DraftCtx, snapshot *DraftSnapshot, patch Patch) error {
if err := patch.Validate(); err != nil {
return err
}
hasBodyChange := false
for _, op := range patch.Ops {
if err := applyOp(snapshot, op, patch.Options); err != nil {
if err := applyOp(dctx, snapshot, op, patch.Options); err != nil {
return err
}
if bodyChangingOps[op.Op] {
hasBodyChange = true
}
}
if err := postProcessInlineImages(snapshot, hasBodyChange); err != nil {
if err := postProcessInlineImages(dctx, snapshot, hasBodyChange); err != nil {
return err
}
return refreshSnapshot(snapshot)
}
func applyOp(snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error {
func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error {
switch op.Op {
case "set_subject":
if strings.ContainsAny(op.Value, "\r\n") {
@@ -100,7 +100,7 @@ func applyOp(snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error {
}
removeHeader(&snapshot.Headers, op.Name)
case "add_attachment":
return addAttachment(snapshot, op.Path)
return addAttachment(dctx, snapshot, op.Path)
case "remove_attachment":
partID, err := resolveTarget(snapshot, op.Target)
if err != nil {
@@ -108,13 +108,13 @@ func applyOp(snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error {
}
return removeAttachment(snapshot, partID)
case "add_inline":
return addInline(snapshot, op.Path, op.CID, op.FileName, op.ContentType)
return addInline(dctx, snapshot, op.Path, op.CID, op.FileName, op.ContentType)
case "replace_inline":
partID, err := resolveTarget(snapshot, op.Target)
if err != nil {
return fmt.Errorf("replace_inline: %w", err)
}
return replaceInline(snapshot, partID, op.Path, op.CID, op.FileName, op.ContentType)
return replaceInline(dctx, snapshot, partID, op.Path, op.CID, op.FileName, op.ContentType)
case "remove_inline":
partID, err := resolveTarget(snapshot, op.Target)
if err != nil {
@@ -478,22 +478,23 @@ func newMultipartContainer(mediaType string) *Part {
}
}
func addAttachment(snapshot *DraftSnapshot, path string) error {
safePath, err := validate.SafeInputPath(path)
if err != nil {
return fmt.Errorf("attachment %q: %w", path, err)
}
func addAttachment(dctx *DraftCtx, snapshot *DraftSnapshot, path string) error {
if err := checkBlockedExtension(filepath.Base(path)); err != nil {
return err
}
info, err := vfs.Stat(safePath)
info, err := dctx.FIO.Stat(path)
if err != nil {
return err
}
if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), nil); err != nil {
return err
}
content, err := vfs.ReadFile(safePath)
f, err := dctx.FIO.Open(path)
if err != nil {
return err
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
return err
}
@@ -543,19 +544,20 @@ func addAttachment(snapshot *DraftSnapshot, path string) error {
// creates a MIME inline part, and attaches it to the snapshot's
// multipart/related container. If container is non-nil it is reused;
// otherwise the container is resolved from the snapshot.
func loadAndAttachInline(snapshot *DraftSnapshot, path, cid, fileName string, container *Part) (*Part, error) {
safePath, err := validate.SafeInputPath(path)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
info, err := vfs.Stat(safePath)
func loadAndAttachInline(dctx *DraftCtx, snapshot *DraftSnapshot, path, cid, fileName string, container *Part) (*Part, error) {
info, err := dctx.FIO.Stat(path)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), nil); err != nil {
return nil, err
}
content, err := vfs.ReadFile(safePath)
f, err := dctx.FIO.Open(path)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
@@ -567,7 +569,7 @@ func loadAndAttachInline(snapshot *DraftSnapshot, path, cid, fileName string, co
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
inline, err := newInlinePart(safePath, content, cid, name, detectedCT)
inline, err := newInlinePart(path, content, cid, name, detectedCT)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
@@ -586,12 +588,12 @@ func loadAndAttachInline(snapshot *DraftSnapshot, path, cid, fileName string, co
return container, nil
}
func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) error {
_, err := loadAndAttachInline(snapshot, path, cid, fileName, nil)
func addInline(dctx *DraftCtx, snapshot *DraftSnapshot, path, cid, fileName, contentType string) error {
_, err := loadAndAttachInline(dctx, snapshot, path, cid, fileName, nil)
return err
}
func replaceInline(snapshot *DraftSnapshot, partID, path, cid, fileName, contentType string) error {
func replaceInline(dctx *DraftCtx, snapshot *DraftSnapshot, partID, path, cid, fileName, contentType string) error {
part := findPart(snapshot.Body, partID)
if part == nil {
return fmt.Errorf("inline part %q not found", partID)
@@ -599,18 +601,19 @@ func replaceInline(snapshot *DraftSnapshot, partID, path, cid, fileName, content
if !isInlinePart(part) {
return fmt.Errorf("part %q is not an inline MIME part", partID)
}
safePath, err := validate.SafeInputPath(path)
if err != nil {
return fmt.Errorf("inline image %q: %w", path, err)
}
info, err := vfs.Stat(safePath)
info, err := dctx.FIO.Stat(path)
if err != nil {
return err
}
if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), part); err != nil {
return err
}
content, err := vfs.ReadFile(safePath)
f, err := dctx.FIO.Open(path)
if err != nil {
return err
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
return err
}
@@ -990,7 +993,7 @@ func ResolveLocalImagePaths(html string) (string, []LocalImageRef, error) {
// resolveLocalImgSrc scans HTML for <img src="local/path"> references,
// creates MIME inline parts for each local file, and returns the HTML
// with those src attributes replaced by cid: URIs.
func resolveLocalImgSrc(snapshot *DraftSnapshot, html string) (string, error) {
func resolveLocalImgSrc(dctx *DraftCtx, snapshot *DraftSnapshot, html string) (string, error) {
resolved, refs, err := ResolveLocalImagePaths(html)
if err != nil {
return "", err
@@ -999,7 +1002,7 @@ func resolveLocalImgSrc(snapshot *DraftSnapshot, html string) (string, error) {
var container *Part
for _, ref := range refs {
fileName := filepath.Base(ref.FilePath)
container, err = loadAndAttachInline(snapshot, ref.FilePath, ref.CID, fileName, container)
container, err = loadAndAttachInline(dctx, snapshot, ref.FilePath, ref.CID, fileName, container)
if err != nil {
return "", err
}
@@ -1092,7 +1095,7 @@ func FindOrphanedCIDs(html string, addedCIDs []string) []string {
// NOTE: The EML builder path has an equivalent function processInlineImagesForEML
// in shortcuts/mail/helpers.go. When adding new validation or processing logic here,
// update processInlineImagesForEML as well (or extract a shared function).
func postProcessInlineImages(snapshot *DraftSnapshot, resolveLocal bool) error {
func postProcessInlineImages(dctx *DraftCtx, snapshot *DraftSnapshot, resolveLocal bool) error {
htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html")
if htmlPart == nil {
return nil
@@ -1102,7 +1105,7 @@ func postProcessInlineImages(snapshot *DraftSnapshot, resolveLocal bool) error {
html := origHTML
if resolveLocal {
var err error
html, err = resolveLocalImgSrc(snapshot, origHTML)
html, err = resolveLocalImgSrc(dctx, snapshot, origHTML)
if err != nil {
return err
}

View File

@@ -25,9 +25,10 @@ func TestAddAttachmentToNilBodyCreatesRoot(t *testing.T) {
{Name: "From", Value: "alice@example.com"},
},
}
dctx := &DraftCtx{FIO: testFIO}
// Apply manually with a minimal patch (bypass Patch validation since we
// have no body part to detect)
err := addAttachment(snapshot, "file.txt")
err := addAttachment(dctx, snapshot, "file.txt")
if err != nil {
t.Fatalf("addAttachment() error = %v", err)
}
@@ -51,7 +52,7 @@ func TestAddAttachmentToExistingMultipartMixed(t *testing.T) {
}
snapshot := mustParseFixtureDraft(t, fixtureData)
originalChildren := len(snapshot.Body.Children)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "add_attachment", Path: "second.txt"}},
})
if err != nil {
@@ -84,7 +85,7 @@ func TestAddAttachmentBlockedExtensionViaApply(t *testing.T) {
snapshot := mustParseFixtureDraft(t, fixtureData)
for _, name := range blocked {
t.Run(name, func(t *testing.T) {
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "add_attachment", Path: name}},
})
if err == nil {
@@ -111,7 +112,7 @@ func TestAddAttachmentAllowedExtensionViaApply(t *testing.T) {
for _, name := range allowed {
t.Run(name, func(t *testing.T) {
snapshot := mustParseFixtureDraft(t, fixtureData)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "add_attachment", Path: name}},
})
if err != nil {
@@ -142,7 +143,7 @@ Content-Type: text/html; charset=UTF-8
`)
for _, name := range []string{"icon.svg", "evil.png"} {
t.Run(name, func(t *testing.T) {
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "add_inline", Path: name, CID: "img1"}},
})
if err == nil {
@@ -167,7 +168,7 @@ Content-Type: text/html; charset=UTF-8
<div>hello<img src="cid:img1"></div>
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "add_inline", Path: name, CID: "img1"}},
})
if err != nil {
@@ -194,7 +195,7 @@ Content-Type: text/html; charset=UTF-8
<div>hello<img src="cid:img1"></div>
`)
// User passes a spoofed content_type; it should be ignored in favor of detected type.
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "add_inline", Path: "logo.png", CID: "img1", ContentType: "application/octet-stream"}},
})
if err != nil {
@@ -234,7 +235,7 @@ PHN2Zz48L3N2Zz4=
// The old part has image/svg+xml. Replace with a PNG file; the filename
// falls back to the path ("new.png") since the old part's name is "icon.svg"
// which would fail the extension whitelist, so we pass an explicit filename.
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "replace_inline",
Target: AttachmentTarget{PartID: "1.2"},
@@ -257,7 +258,7 @@ PHN2Zz48L3N2Zz4=
func TestRemoveAttachmentRejectsInlinePart(t *testing.T) {
snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/html_inline_draft.eml"))
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_attachment", Target: AttachmentTarget{PartID: "1.2"}}},
})
if err == nil || !strings.Contains(err.Error(), "use remove_inline") {
@@ -280,7 +281,7 @@ Content-Transfer-Encoding: base64
YQ==
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_attachment", Target: AttachmentTarget{PartID: "1"}}},
})
if err == nil || !strings.Contains(err.Error(), "cannot remove root") {
@@ -301,7 +302,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_attachment", Target: AttachmentTarget{PartID: "99"}}},
})
if err == nil || !strings.Contains(err.Error(), "not found") {
@@ -316,7 +317,7 @@ hello
func TestRemoveInlineRejectsNonInlinePart(t *testing.T) {
snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/forward_draft.eml"))
// 1.2 is an attachment in forward_draft, not an inline
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{PartID: "1.2"}}},
})
if err == nil || !strings.Contains(err.Error(), "not an inline") {
@@ -340,7 +341,7 @@ Content-Transfer-Encoding: base64
cG5n
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{PartID: "1"}}},
})
if err == nil || !strings.Contains(err.Error(), "cannot remove root") {
@@ -361,7 +362,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{PartID: "99"}}},
})
if err == nil || !strings.Contains(err.Error(), "not found") {
@@ -376,7 +377,7 @@ hello
func TestResolveTargetByCID(t *testing.T) {
snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/html_inline_draft.eml"))
// Remove via CID target
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "replace_inline",
Target: AttachmentTarget{CID: "logo"},
@@ -397,7 +398,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{CID: "nonexistent"}}},
})
if err == nil || !strings.Contains(err.Error(), "no part with cid") {
@@ -433,7 +434,7 @@ func TestReplaceInlineRejectsNonInlinePart(t *testing.T) {
t.Fatal(err)
}
snapshot := mustParseFixtureDraft(t, fixtureData)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "replace_inline",
Target: AttachmentTarget{PartID: "1.2"},
@@ -462,7 +463,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "replace_inline",
Target: AttachmentTarget{PartID: "99"},

View File

@@ -21,7 +21,7 @@ Content-Type: text/html; charset=UTF-8
<p>hello</p>
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: "<div>updated</div>"}},
})
if err != nil {
@@ -43,7 +43,7 @@ Content-Type: text/html; charset=UTF-8
func TestApplySetBodyNoPrimaryBodyFails(t *testing.T) {
// A multipart/signed draft has no editable primary body
snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/multipart_signed_draft.eml"))
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: "anything"}},
})
if err == nil || !strings.Contains(err.Error(), "no unique primary body") {
@@ -65,7 +65,7 @@ Content-Type: text/html; charset=UTF-8
<div>old reply</div>`+quoteHTML+`
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_reply_body", Value: "<div>new reply</div>"}},
})
if err != nil {
@@ -101,7 +101,7 @@ Content-Type: text/html; charset=UTF-8
<div>old note</div>`+quoteHTML+`
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_reply_body", Value: "<div>updated note</div>"}},
})
if err != nil {
@@ -130,7 +130,7 @@ Content-Type: text/html; charset=UTF-8
<p>original body</p>
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_reply_body", Value: "<div>replaced</div>"}},
})
if err != nil {
@@ -164,7 +164,7 @@ Content-Type: text/html; charset=UTF-8
<div>old reply</div>`+quoteHTML+`
--alt--
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_reply_body", Value: "<div>new reply</div>"}},
})
if err != nil {
@@ -201,7 +201,7 @@ Content-Type: text/plain; charset=UTF-8
original text
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_reply_body", Value: "replaced text"}},
})
if err != nil {
@@ -226,7 +226,7 @@ Content-Type: text/plain; charset=UTF-8
original content
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/plain", Value: "replaced content"}},
})
if err != nil {
@@ -247,7 +247,7 @@ Content-Type: text/plain; charset=UTF-8
original
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Value: " appended"}},
})
if err != nil {
@@ -272,7 +272,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/csv", Value: "data"}},
})
if err == nil || !strings.Contains(err.Error(), "body_kind must be text/plain or text/html") {
@@ -293,7 +293,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/html", Value: "<p>new</p>"}},
})
if err == nil || !strings.Contains(err.Error(), "no primary text/html body part") {
@@ -322,7 +322,7 @@ Content-Type: text/html; charset=UTF-8
<p>real body</p>
--alt--
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: "just plain text without any tags"}},
})
if err == nil || !strings.Contains(err.Error(), "requires HTML input") {
@@ -343,7 +343,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{
{Op: "set_subject", Value: "Updated Subject"},
{Op: "add_recipient", Field: "cc", Name: "Carol", Address: "carol@example.com"},

View File

@@ -21,7 +21,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "set_reply_to",
Addresses: []Address{{Name: "Support", Address: "support@example.com"}},
@@ -48,7 +48,7 @@ hello
if len(snapshot.ReplyTo) == 0 {
t.Fatalf("ReplyTo should be set before clear")
}
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "clear_reply_to"}},
})
if err != nil {
@@ -76,7 +76,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_header", Name: "X-Priority"}},
})
if err != nil {
@@ -96,7 +96,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_header", Name: "Content-Type"}},
})
if err == nil || !strings.Contains(err.Error(), "protected") {
@@ -114,7 +114,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_header", Name: "Reply-To"}},
Options: PatchOptions{AllowProtectedHeaderEdits: true},
})
@@ -139,7 +139,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_header", Name: "Bad:Name", Value: "value"}},
})
if err == nil || !strings.Contains(err.Error(), "must not contain") {
@@ -156,7 +156,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_header", Name: "X-Custom", Value: "val\r\ninjected"}},
})
if err == nil || !strings.Contains(err.Error(), "must not contain") {
@@ -177,7 +177,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_subject", Value: "Subject\ninjection"}},
})
if err == nil || !strings.Contains(err.Error(), "must not contain") {
@@ -198,7 +198,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "unknown_op"}},
})
if err == nil || !strings.Contains(err.Error(), "unsupported") {

View File

@@ -26,7 +26,7 @@ Content-Type: text/html; charset=UTF-8
<div>Hello<img src="./logo.png" /></div>
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div>Hello<img src="./logo.png" /></div>`}},
})
if err != nil {
@@ -79,7 +79,7 @@ Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./a.png" /><img src="./b.jpg" /></div>`}},
})
if err != nil {
@@ -126,7 +126,7 @@ cG5n
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
originalBody := string(htmlPart.Body)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: originalBody}},
})
if err != nil {
@@ -156,7 +156,7 @@ Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./logo.png" /><img src="./sub/logo.png" /></div>`}},
})
if err != nil {
@@ -190,7 +190,7 @@ Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./logo.png" /><p>text</p><img src="./logo.png" /></div>`}},
})
if err != nil {
@@ -235,7 +235,7 @@ Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./doc.txt" /></div>`}},
})
if err == nil {
@@ -268,7 +268,7 @@ cG5n
--rel--
`)
// Remove the <img> tag from body.
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: "<div>hello</div>"}},
})
if err != nil {
@@ -309,7 +309,7 @@ cG5n
--rel--
`)
// Replace old image reference with a new local file.
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./new.png" /></div>`}},
})
if err != nil {
@@ -352,7 +352,7 @@ Content-Type: text/html; charset=UTF-8
<div>original reply</div><div class="history-quote-wrapper"><div>quoted text</div></div>
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_reply_body", Value: `<div>new reply<img src="./photo.png" /></div>`}},
})
if err != nil {
@@ -402,7 +402,7 @@ Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{
{Op: "add_inline", Path: "a.png", CID: "a"},
{Op: "set_body", Value: `<div><img src="cid:a" /><img src="./b.png" /></div>`},
@@ -449,7 +449,7 @@ Content-Type: text/html; charset=UTF-8
`)
// add_inline creates CID "logo", but body uses local path instead of cid:logo.
// resolve generates a UUID CID, orphan cleanup removes the unused "logo".
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{
{Op: "add_inline", Path: "logo.png", CID: "logo"},
{Op: "set_body", Value: `<div><img src="./logo.png" /></div>`},
@@ -503,7 +503,7 @@ cG5n
--rel--
`)
// remove_inline removes the MIME part, but set_body still references cid:logo.
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{
{Op: "remove_inline", Target: AttachmentTarget{CID: "logo"}},
{Op: "set_body", Value: `<div><img src="cid:logo" /></div>`},
@@ -541,7 +541,7 @@ Content-Transfer-Encoding: base64
cG5n
--rel--
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{
{Op: "remove_inline", Target: AttachmentTarget{CID: "old"}},
{Op: "set_body", Value: `<div><img src="./new.png" /></div>`},
@@ -584,7 +584,7 @@ Content-Type: text/plain; charset=UTF-8
Just plain text.
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: "Updated plain text."}},
})
if err != nil {
@@ -631,7 +631,7 @@ cG5n
`)
// A metadata-only edit should not destroy the HTML body part even though
// its Content-ID is not referenced by any <img src="cid:...">.
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_subject", Value: "Updated subject"}},
})
if err != nil {
@@ -901,7 +901,7 @@ func TestSetBodyReplacesOrphanedInlineUnderMixed(t *testing.T) {
}
// Apply set_body with a local image path (triggers auto-resolve).
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<p>111<img src="./Peter1.jpeg" /></p><p>222</p>`}},
})
if err != nil {
@@ -970,7 +970,7 @@ Content-Type: text/html; charset=UTF-8
<div>Hello<img src="./nonexistent-image.png" /></div>
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_subject", Value: "Updated subject"}},
})
if err != nil {

View File

@@ -21,7 +21,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "add_recipient",
Field: "to",
@@ -49,7 +49,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "add_recipient",
Field: "to",
@@ -74,7 +74,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "add_recipient",
Field: "cc",
@@ -99,7 +99,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "add_recipient",
Field: "bcc",
@@ -124,7 +124,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "add_recipient",
Field: "to",
@@ -150,7 +150,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "remove_recipient",
Field: "to",
@@ -177,7 +177,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "remove_recipient",
Field: "to",
@@ -201,7 +201,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "remove_recipient",
Field: "to",
@@ -222,7 +222,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "remove_recipient",
Field: "cc",
@@ -244,7 +244,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "remove_recipient",
Field: "cc",
@@ -276,7 +276,7 @@ Content-Type: text/plain; charset=UTF-8
hello
`)
err := Apply(snapshot, Patch{
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "set_recipients",
Field: "cc",

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