mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69cf9f206e | ||
|
|
99b8aaa556 | ||
|
|
b4a26b2cdc | ||
|
|
619ec8c2cb | ||
|
|
eb3c643f0b | ||
|
|
37747177fc | ||
|
|
9d48ef422b | ||
|
|
e64d24580a | ||
|
|
3db4f42ab8 | ||
|
|
0bf4f80ef4 | ||
|
|
284e5b6606 | ||
|
|
af83e5495b | ||
|
|
a3bced3ee5 | ||
|
|
35108e1798 | ||
|
|
30b97e1bdd | ||
|
|
2715b560b7 | ||
|
|
15bd134f5c | ||
|
|
daa21731ad | ||
|
|
9fab62bf00 | ||
|
|
cdd9f9ab49 | ||
|
|
d5d31f0ee4 | ||
|
|
67cb0a961e | ||
|
|
aa4076a7cc | ||
|
|
db9ca5c2a4 | ||
|
|
1f8d4b211d | ||
|
|
63ea52b2e6 | ||
|
|
555722ac8e | ||
|
|
f5a8fbf8f1 | ||
|
|
adef52ada5 | ||
|
|
6ac5b4d566 | ||
|
|
7158dc2f3c | ||
|
|
c54a1354a0 | ||
|
|
a73c9ae27e | ||
|
|
900c12ce8d | ||
|
|
f3c3a4c49f | ||
|
|
2e345a4fdd | ||
|
|
78bc66ce14 | ||
|
|
fe8da8d924 | ||
|
|
12bb01addf | ||
|
|
d6fada01f5 |
26
.github/workflows/license-header.yml
vendored
Normal file
26
.github/workflows/license-header.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: License Header
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- "**/*.js"
|
||||
- "**/*.py"
|
||||
- .licenserc.yaml
|
||||
- .github/workflows/license-header.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
header-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Check license headers
|
||||
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
|
||||
with:
|
||||
config: .licenserc.yaml
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,6 +31,7 @@ tests/mail/reports/
|
||||
|
||||
/log/
|
||||
|
||||
|
||||
# Generated / test artifacts
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
|
||||
@@ -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
16
.licenserc.yaml
Normal 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
|
||||
65
CHANGELOG.md
65
CHANGELOG.md
@@ -2,6 +2,69 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.7] - 2026-04-09
|
||||
|
||||
### Features
|
||||
|
||||
- Auto-grant current user access for bot-created docs, sheets, imports, and uploads (#360)
|
||||
- **mail**: Add `send_as` alias support, mailbox/sender discovery APIs, and mail rules API
|
||||
- **vc**: Extract note doc tokens from calendar event relation API (#333)
|
||||
- **wiki**: Add wiki node create shortcut (#320)
|
||||
- **sheets**: Add `+write-image` shortcut (#343)
|
||||
- **docs**: Add media-preview shortcut (#334)
|
||||
- **docs**: Add support for additional search filters (#353)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api**: Support stdin and quoted JSON inputs on Windows (#367)
|
||||
- **doc**: Post-process `docs +fetch` output to improve round-trip fidelity (#214)
|
||||
- **run**: Add missing binary check for lark-cli execution (#362)
|
||||
- **config**: Validate appId and appSecret keychain key consistency (#295)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Route base import guidance to drive `+import` (#368)
|
||||
- Migrate mail shortcuts to FileIO (#356)
|
||||
- Migrate drive/doc/sheets shortcuts to FileIO (#339)
|
||||
- Migrate base shortcuts to FileIO (#347)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-doc**: Document advanced boolean and intitle search syntax for AI agents (#210)
|
||||
|
||||
### Chore
|
||||
|
||||
- Add depguard and forbidigo rules to guide FileIO adoption (#342)
|
||||
|
||||
## [v1.0.6] - 2026-04-08
|
||||
|
||||
### Features
|
||||
|
||||
- Improve login scope validation and success output (#317)
|
||||
- **task**: Support starting pagination from page token (#332)
|
||||
- Support multipart doc media uploads (#294)
|
||||
- **mail**: Auto-resolve local image paths in all draft entry points (#205)
|
||||
- **vc**: Add `+recording` shortcut for `meeting_id` to `minute_token` conversion (#246)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Resolve concurrency races in RuntimeContext (#330)
|
||||
- **config**: Save empty config before clearing keychain entries (#291)
|
||||
- Reject positional arguments in shortcuts (#227)
|
||||
- Improve raw API diagnostics for invalid or empty JSON responses (#257)
|
||||
- **docs**: Normalize `board_tokens` in `+create` response for mermaid/whiteboard content (#10)
|
||||
- **task**: Clarify `--complete` flag help for `get-my-tasks` (#310)
|
||||
- **help**: Point root help Agent Skills link to README section (#289)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Clarify `--complete` flag behavior in `get-my-tasks` reference (#308)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Migrate VC/minutes shortcuts to FileIO (#336)
|
||||
- Migrate common/client/IM to FileIO and add localfileio tests (#322)
|
||||
|
||||
## [v1.0.5] - 2026-04-07
|
||||
|
||||
### Features
|
||||
@@ -193,6 +256,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
|
||||
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
|
||||
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
|
||||
[v1.0.4]: https://github.com/larksuite/cli/releases/tag/v1.0.4
|
||||
[v1.0.3]: https://github.com/larksuite/cli/releases/tag/v1.0.3
|
||||
|
||||
@@ -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
|
||||
@@ -199,7 +187,7 @@ func apiRun(opts *APIOptions) error {
|
||||
|
||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||
if err != nil {
|
||||
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||
return output.MarkRaw(client.WrapDoAPIError(err))
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
@@ -207,6 +195,7 @@ func apiRun(opts *APIOptions) error {
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
})
|
||||
// MarkRaw tells root error handler to skip enrichPermissionError,
|
||||
// preserving the original API error detail (log_id, troubleshooter, etc.).
|
||||
|
||||
@@ -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,
|
||||
@@ -446,6 +462,43 @@ func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/invalidjson",
|
||||
RawBody: []byte{},
|
||||
ContentType: "application/json",
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected detail on exit error")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
|
||||
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
|
||||
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--output") {
|
||||
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
@@ -100,7 +101,7 @@ type appInfoResponse struct {
|
||||
|
||||
// getAppInfo queries app info from the Lark API.
|
||||
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||
sdk, err := f.LarkClient()
|
||||
ac, err := f.NewAPIClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -108,12 +109,11 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("lang", "zh_cn")
|
||||
|
||||
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: larkauth.ApplicationInfoPath(appId),
|
||||
QueryParams: queryParams,
|
||||
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
|
||||
})
|
||||
apiResp, err := ac.DoSDKRequest(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: larkauth.ApplicationInfoPath(appId),
|
||||
QueryParams: queryParams,
|
||||
}, core.AsBot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
@@ -231,3 +235,71 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
|
||||
appInfoStub := &httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/application/v6/applications/test-app",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"creator_id": "ou_creator",
|
||||
"scopes": []map[string]interface{}{
|
||||
{
|
||||
"scope": "im:message",
|
||||
"token_types": []string{"tenant"},
|
||||
},
|
||||
{
|
||||
"scope": "im:message:send_as_user",
|
||||
"token_types": []string{"user"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(appInfoStub)
|
||||
|
||||
err := authScopesRun(&ScopesOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Format: "json",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authScopesRun() error = %v", err)
|
||||
}
|
||||
|
||||
if len(tokenResolver.requests) != 1 {
|
||||
t.Fatalf("resolved token requests = %v, want exactly one request", tokenResolver.requests)
|
||||
}
|
||||
if got := tokenResolver.requests[0].Type; got != credential.TokenTypeTAT {
|
||||
t.Fatalf("resolved token type = %q, want %q", got, credential.TokenTypeTAT)
|
||||
}
|
||||
if got := appInfoStub.CapturedHeaders.Get("Authorization"); got != "Bearer tenant-token" {
|
||||
t.Fatalf("Authorization header = %q, want %q", got, "Bearer tenant-token")
|
||||
}
|
||||
}
|
||||
|
||||
type authScopesTokenResolver struct {
|
||||
requests []credential.TokenSpec
|
||||
}
|
||||
|
||||
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
r.requests = append(r.requests, req)
|
||||
switch req.Type {
|
||||
case credential.TokenTypeTAT:
|
||||
return &credential.TokenResult{Token: "tenant-token"}, nil
|
||||
case credential.TokenTypeUAT:
|
||||
return &credential.TokenResult{Token: "user-token"}, nil
|
||||
default:
|
||||
return &credential.TokenResult{Token: "unexpected-token"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ type LoginOptions struct {
|
||||
DeviceCode string
|
||||
}
|
||||
|
||||
var pollDeviceToken = larkauth.PollDeviceToken
|
||||
|
||||
// NewCmdAuthLogin creates the auth login subcommand.
|
||||
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
opts := &LoginOptions{Factory: f}
|
||||
@@ -235,6 +237,9 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// --no-wait: return immediately with device code and URL
|
||||
if opts.NoWait {
|
||||
if err := saveLoginRequestedScope(authResp.DeviceCode, finalScope); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to cache requested scopes: %v\n", err)
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
@@ -244,7 +249,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -261,7 +266,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
@@ -270,20 +275,26 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// Step 3: Poll for token
|
||||
log(msg.WaitingAuth)
|
||||
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(map[string]interface{}{
|
||||
"event": "authorization_failed",
|
||||
"error": result.Message,
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
}); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||
}
|
||||
if result.Token == nil {
|
||||
return output.ErrAuth("authorization succeeded but no token returned")
|
||||
}
|
||||
|
||||
// Step 6: Get user info
|
||||
log(msg.AuthSuccess)
|
||||
@@ -296,6 +307,8 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
return output.ErrAuth("failed to get user info: %v", err)
|
||||
}
|
||||
|
||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
|
||||
|
||||
// Step 7: Store token
|
||||
now := time.Now().UnixMilli()
|
||||
storedToken := &larkauth.StoredUAToken{
|
||||
@@ -318,21 +331,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
"event": "authorization_complete",
|
||||
"user_open_id": openId,
|
||||
"user_name": userName,
|
||||
"scope": result.Token.Scope,
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
if result.Token.Scope != "" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.GrantedScopes, result.Token.Scope)
|
||||
}
|
||||
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
|
||||
}
|
||||
|
||||
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -345,13 +348,26 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requestedScope, err := loadLoginRequestedScope(opts.DeviceCode)
|
||||
if err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to load cached requested scopes: %v\n", err)
|
||||
}
|
||||
cleanupRequestedScope := func() {
|
||||
if err := removeLoginRequestedScope(opts.DeviceCode); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
if shouldRemoveLoginRequestedScope(result) {
|
||||
cleanupRequestedScope()
|
||||
}
|
||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||
}
|
||||
defer cleanupRequestedScope()
|
||||
if result.Token == nil {
|
||||
return output.ErrAuth("authorization succeeded but no token returned")
|
||||
}
|
||||
@@ -367,6 +383,8 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
return output.ErrAuth("failed to get user info: %v", err)
|
||||
}
|
||||
|
||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
|
||||
|
||||
// Store token
|
||||
now := time.Now().UnixMilli()
|
||||
storedToken := &larkauth.StoredUAToken{
|
||||
@@ -389,7 +407,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
|
||||
}
|
||||
|
||||
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,17 @@ type loginMsg struct {
|
||||
ConfirmAuth string
|
||||
|
||||
// Non-interactive prompts (login.go)
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
GrantedScopes string
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
ScopeMismatch string
|
||||
ScopeHint string
|
||||
RequestedScopes string
|
||||
NewlyGrantedScopes string
|
||||
MissingScopes string
|
||||
NoScopes string
|
||||
StatusHint string
|
||||
|
||||
// Non-interactive hint (no flags)
|
||||
HintHeader string
|
||||
@@ -50,11 +56,17 @@ var loginMsgZh = &loginMsg{
|
||||
ErrNoDomain: "请至少选择一个业务域",
|
||||
ConfirmAuth: "确认授权?",
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %s (%s)",
|
||||
GrantedScopes: " 已授权 scopes: %s\n",
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %s (%s)",
|
||||
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
|
||||
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
RequestedScopes: " 本次请求 scopes: %s\n",
|
||||
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
|
||||
MissingScopes: " 本次未授予 scopes: %s\n",
|
||||
NoScopes: "(空)",
|
||||
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
|
||||
HintHeader: "请指定要授权的权限:\n",
|
||||
HintCommon1: " --recommend 授权推荐权限",
|
||||
@@ -79,11 +91,17 @@ var loginMsgEn = &loginMsg{
|
||||
ErrNoDomain: "please select at least one domain",
|
||||
ConfirmAuth: "Confirm authorization?",
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AuthSuccess: "Authorization successful, fetching user info...",
|
||||
LoginSuccess: "Login successful! User: %s (%s)",
|
||||
GrantedScopes: " Granted scopes: %s\n",
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AuthSuccess: "Authorization successful, fetching user info...",
|
||||
LoginSuccess: "Login successful! User: %s (%s)",
|
||||
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
|
||||
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
RequestedScopes: " Requested scopes: %s\n",
|
||||
NewlyGrantedScopes: " Newly granted scopes: %s\n",
|
||||
MissingScopes: " Not granted scopes: %s\n",
|
||||
NoScopes: "(none)",
|
||||
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
|
||||
HintHeader: "Please specify the scopes to authorize:\n",
|
||||
HintCommon1: " --recommend authorize recommended scopes",
|
||||
|
||||
@@ -69,12 +69,6 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
t.Errorf("%s LoginSuccess has no format verb", lang)
|
||||
}
|
||||
|
||||
// GrantedScopes should contain %s
|
||||
got = fmt.Sprintf(msg.GrantedScopes, "scope1 scope2")
|
||||
if got == msg.GrantedScopes {
|
||||
t.Errorf("%s GrantedScopes has no format verb", lang)
|
||||
}
|
||||
|
||||
// SummaryDomains should contain %s
|
||||
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
|
||||
if got == msg.SummaryDomains {
|
||||
|
||||
235
cmd/auth/login_result.go
Normal file
235
cmd/auth/login_result.go
Normal file
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type loginScopeSummary struct {
|
||||
Requested []string
|
||||
NewlyGranted []string
|
||||
AlreadyGranted []string
|
||||
Granted []string
|
||||
Missing []string
|
||||
}
|
||||
|
||||
type loginScopeIssue struct {
|
||||
Message string
|
||||
Hint string
|
||||
Summary *loginScopeSummary
|
||||
}
|
||||
|
||||
// ensureRequestedScopesGranted checks whether all requested scopes were granted
|
||||
// and returns a structured issue when any requested scope is missing.
|
||||
func ensureRequestedScopesGranted(requestedScope, grantedScope string, msg *loginMsg, summary *loginScopeSummary) *loginScopeIssue {
|
||||
requested := uniqueScopeList(requestedScope)
|
||||
if len(requested) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := larkauth.MissingScopes(grantedScope, requested)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{
|
||||
Requested: requested,
|
||||
Granted: strings.Fields(grantedScope),
|
||||
Missing: missing,
|
||||
}
|
||||
}
|
||||
return &loginScopeIssue{
|
||||
Message: fmt.Sprintf(msg.ScopeMismatch, strings.Join(missing, " ")),
|
||||
Hint: msg.ScopeHint,
|
||||
Summary: summary,
|
||||
}
|
||||
}
|
||||
|
||||
// loadLoginScopeSummary builds a scope summary by comparing the requested scopes,
|
||||
// previously stored scopes, and the newly granted scopes from the current login.
|
||||
func loadLoginScopeSummary(appID, openId, requestedScope, grantedScope string) *loginScopeSummary {
|
||||
previousScope := ""
|
||||
if previous := larkauth.GetStoredToken(appID, openId); previous != nil {
|
||||
previousScope = previous.Scope
|
||||
}
|
||||
return buildLoginScopeSummary(requestedScope, previousScope, grantedScope)
|
||||
}
|
||||
|
||||
// buildLoginScopeSummary classifies requested scopes into newly granted,
|
||||
// already granted, and missing buckets while preserving the final granted list.
|
||||
func buildLoginScopeSummary(requestedScope, previousScope, grantedScope string) *loginScopeSummary {
|
||||
requested := uniqueScopeList(requestedScope)
|
||||
previous := uniqueScopeList(previousScope)
|
||||
granted := uniqueScopeList(grantedScope)
|
||||
previousSet := make(map[string]bool, len(previous))
|
||||
for _, scope := range previous {
|
||||
previousSet[scope] = true
|
||||
}
|
||||
grantedSet := make(map[string]bool, len(granted))
|
||||
for _, scope := range granted {
|
||||
grantedSet[scope] = true
|
||||
}
|
||||
|
||||
summary := &loginScopeSummary{
|
||||
Requested: requested,
|
||||
Granted: granted,
|
||||
}
|
||||
for _, scope := range requested {
|
||||
if !grantedSet[scope] {
|
||||
summary.Missing = append(summary.Missing, scope)
|
||||
continue
|
||||
}
|
||||
if previousSet[scope] {
|
||||
summary.AlreadyGranted = append(summary.AlreadyGranted, scope)
|
||||
continue
|
||||
}
|
||||
summary.NewlyGranted = append(summary.NewlyGranted, scope)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// uniqueScopeList splits a scope string into a de-duplicated ordered slice.
|
||||
func uniqueScopeList(scope string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var result []string
|
||||
for _, item := range strings.Fields(scope) {
|
||||
if seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// formatScopeList joins scopes for display and falls back to the provided empty
|
||||
// label when the input slice is empty.
|
||||
func formatScopeList(scopes []string, empty string) string {
|
||||
if len(scopes) == 0 {
|
||||
return empty
|
||||
}
|
||||
return strings.Join(scopes, " ")
|
||||
}
|
||||
|
||||
// emptyIfNil normalizes nil slices to empty slices for stable JSON output.
|
||||
func emptyIfNil(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
|
||||
// breakdown to stderr.
|
||||
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
|
||||
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
|
||||
fmt.Fprintf(errOut.ErrOut, msg.MissingScopes, formatScopeList(summary.Missing, msg.NoScopes))
|
||||
}
|
||||
|
||||
// writeLoginSuccess emits the successful login payload in either JSON or text
|
||||
// format together with the computed scope breakdown.
|
||||
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary) {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, summary)
|
||||
if len(summary.Missing) == 0 && msg.StatusHint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.StatusHint)
|
||||
}
|
||||
}
|
||||
|
||||
// handleLoginScopeIssue prints or returns a structured missing-scope result
|
||||
// while preserving a successful login outcome when authorization completed.
|
||||
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName string) error {
|
||||
if issue == nil {
|
||||
return nil
|
||||
}
|
||||
loginSucceeded := openId != ""
|
||||
if opts.JSON {
|
||||
if loginSucceeded {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return nil
|
||||
}
|
||||
detail := map[string]interface{}{
|
||||
"requested": issue.Summary.Requested,
|
||||
"granted": issue.Summary.Granted,
|
||||
"missing": issue.Summary.Missing,
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "missing_scope",
|
||||
Message: issue.Message,
|
||||
Hint: issue.Hint,
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
if loginSucceeded {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
if loginSucceeded {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
|
||||
if issue.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
|
||||
}
|
||||
if loginSucceeded {
|
||||
return nil
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
|
||||
// authorizationCompletePayload builds the JSON payload for a completed login,
|
||||
// optionally attaching a warning when requested scopes are missing.
|
||||
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue) map[string]interface{} {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"event": "authorization_complete",
|
||||
"user_open_id": openId,
|
||||
"user_name": userName,
|
||||
"scope": strings.Join(summary.Granted, " "),
|
||||
"requested": emptyIfNil(summary.Requested),
|
||||
"newly_granted": emptyIfNil(summary.NewlyGranted),
|
||||
"already_granted": emptyIfNil(summary.AlreadyGranted),
|
||||
"missing": emptyIfNil(summary.Missing),
|
||||
"granted": emptyIfNil(summary.Granted),
|
||||
}
|
||||
if issue != nil {
|
||||
payload["warning"] = map[string]interface{}{
|
||||
"type": "missing_scope",
|
||||
"message": issue.Message,
|
||||
"hint": issue.Hint,
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
94
cmd/auth/login_scope_cache.go
Normal file
94
cmd/auth/login_scope_cache.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
var loginScopeCacheSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
type loginScopeCacheRecord struct {
|
||||
RequestedScope string `json:"requested_scope"`
|
||||
}
|
||||
|
||||
// loginScopeCacheDir returns the directory used to persist auth login --no-wait
|
||||
// requested scopes keyed by device_code.
|
||||
func loginScopeCacheDir() string {
|
||||
return filepath.Join(core.GetConfigDir(), "cache", "auth_login_scopes")
|
||||
}
|
||||
|
||||
// loginScopeCachePath returns the cache file path for a given device_code.
|
||||
func loginScopeCachePath(deviceCode string) string {
|
||||
return filepath.Join(loginScopeCacheDir(), sanitizeLoginScopeCacheKey(deviceCode)+".json")
|
||||
}
|
||||
|
||||
// sanitizeLoginScopeCacheKey converts a device_code into a safe filename token.
|
||||
func sanitizeLoginScopeCacheKey(deviceCode string) string {
|
||||
sanitized := loginScopeCacheSafeChars.ReplaceAllString(deviceCode, "_")
|
||||
if sanitized == "" {
|
||||
return "default"
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// saveLoginRequestedScope persists the requested scope string for a device_code.
|
||||
func saveLoginRequestedScope(deviceCode, requestedScope string) error {
|
||||
if err := vfs.MkdirAll(loginScopeCacheDir(), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(loginScopeCacheRecord{RequestedScope: requestedScope})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(loginScopeCachePath(deviceCode), data, 0600)
|
||||
}
|
||||
|
||||
// loadLoginRequestedScope loads the cached requested scope string for a device_code.
|
||||
// It returns an empty string if no cache entry exists.
|
||||
func loadLoginRequestedScope(deviceCode string) (string, error) {
|
||||
data, err := vfs.ReadFile(loginScopeCachePath(deviceCode))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
var record loginScopeCacheRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
_ = vfs.Remove(loginScopeCachePath(deviceCode))
|
||||
return "", err
|
||||
}
|
||||
return record.RequestedScope, nil
|
||||
}
|
||||
|
||||
// removeLoginRequestedScope deletes the cache entry for a device_code.
|
||||
func removeLoginRequestedScope(deviceCode string) error {
|
||||
err := vfs.Remove(loginScopeCachePath(deviceCode))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// shouldRemoveLoginRequestedScope indicates whether the requested-scope cache
|
||||
// should be removed after polling finishes.
|
||||
func shouldRemoveLoginRequestedScope(result *larkauth.DeviceFlowResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
if result.OK || result.Error == "access_denied" {
|
||||
return true
|
||||
}
|
||||
return result.Error == "expired_token" && result.Message != "Polling was cancelled"
|
||||
}
|
||||
51
cmd/auth/login_scope_cache_test.go
Normal file
51
cmd/auth/login_scope_cache_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func TestLoginRequestedScopeCache_RoundTrip(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
deviceCode := "device/code:123"
|
||||
requestedScope := "im:message:send im:message:reply"
|
||||
|
||||
if err := saveLoginRequestedScope(deviceCode, requestedScope); err != nil {
|
||||
t.Fatalf("saveLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
got, err := loadLoginRequestedScope(deviceCode)
|
||||
if err != nil {
|
||||
t.Fatalf("loadLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
if got != requestedScope {
|
||||
t.Fatalf("requestedScope = %q, want %q", got, requestedScope)
|
||||
}
|
||||
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); err != nil {
|
||||
t.Fatalf("Stat(cachePath) error = %v", err)
|
||||
}
|
||||
if err := removeLoginRequestedScope(deviceCode); err != nil {
|
||||
t.Fatalf("removeLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("Stat(cachePath) error = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLoginRequestedScope_MissingReturnsEmpty(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
got, err := loadLoginRequestedScope("missing-device-code")
|
||||
if err != nil {
|
||||
t.Fatalf("loadLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("requestedScope = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
|
||||
@@ -5,16 +5,29 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
type failWriter struct{}
|
||||
|
||||
func (failWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("write failed")
|
||||
}
|
||||
|
||||
func TestSuggestDomain_PrefixMatch(t *testing.T) {
|
||||
known := map[string]bool{
|
||||
"calendar": true,
|
||||
@@ -282,6 +295,606 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRequestedScopesGranted(t *testing.T) {
|
||||
issue := ensureRequestedScopesGranted("im:message:send im:message:reply", "im:message:reply", getLoginMsg("en"), nil)
|
||||
if issue == nil {
|
||||
t.Fatal("expected missing scope issue")
|
||||
}
|
||||
if !strings.Contains(issue.Message, "im:message:send") {
|
||||
t.Fatalf("message %q missing requested scope", issue.Message)
|
||||
}
|
||||
for _, want := range []string{"Do not retry continuously", "scope being disabled", "lark-cli auth status"} {
|
||||
if !strings.Contains(issue.Hint, want) {
|
||||
t.Fatalf("hint %q missing %q", issue.Hint, want)
|
||||
}
|
||||
}
|
||||
if got := strings.Join(issue.Summary.Missing, " "); got != "im:message:send" {
|
||||
t.Fatalf("Missing = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLoginScopeSummary(t *testing.T) {
|
||||
summary := buildLoginScopeSummary("im:message:send im:message:reply im:message:send", "im:message:reply", "im:message:send im:message:reply im:chat:read")
|
||||
if got := strings.Join(summary.Requested, " "); got != "im:message:send im:message:reply" {
|
||||
t.Fatalf("Requested = %q", got)
|
||||
}
|
||||
if got := strings.Join(summary.NewlyGranted, " "); got != "im:message:send" {
|
||||
t.Fatalf("NewlyGranted = %q", got)
|
||||
}
|
||||
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
|
||||
t.Fatalf("AlreadyGranted = %q", got)
|
||||
}
|
||||
if len(summary.Missing) != 0 {
|
||||
t.Fatalf("Missing = %v, want empty", summary.Missing)
|
||||
}
|
||||
if got := strings.Join(summary.Granted, " "); got != "im:message:send im:message:reply im:chat:read" {
|
||||
t.Fatalf("Granted = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
|
||||
Requested: []string{"im:message:send", "im:message:reply"},
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
AlreadyGranted: []string{"im:message:reply"},
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
})
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
|
||||
}
|
||||
if data["event"] != "authorization_complete" {
|
||||
t.Fatalf("event = %v", data["event"])
|
||||
}
|
||||
if data["scope"] != "im:message:send im:message:reply" {
|
||||
t.Fatalf("scope = %v", data["scope"])
|
||||
}
|
||||
if len(data["newly_granted"].([]interface{})) != 1 {
|
||||
t.Fatalf("newly_granted = %#v", data["newly_granted"])
|
||||
}
|
||||
if len(data["already_granted"].([]interface{})) != 1 {
|
||||
t.Fatalf("already_granted = %#v", data["already_granted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
|
||||
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
Hint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
|
||||
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
|
||||
Hint: "Granted scopes: base:app:copy. Check app scopes.",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
|
||||
}
|
||||
if data["event"] != "authorization_complete" {
|
||||
t.Fatalf("event = %v", data["event"])
|
||||
}
|
||||
if data["user_open_id"] != "ou_user" {
|
||||
t.Fatalf("user_open_id = %v", data["user_open_id"])
|
||||
}
|
||||
warning, ok := data["warning"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("warning = %#v", data["warning"])
|
||||
}
|
||||
if warning["type"] != "missing_scope" {
|
||||
t.Fatalf("warning.type = %v", warning["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
|
||||
Granted: []string{"offline_access"},
|
||||
})
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
|
||||
}
|
||||
for _, k := range []string{"requested", "newly_granted", "already_granted", "missing", "granted"} {
|
||||
v, ok := data[k]
|
||||
if !ok {
|
||||
t.Fatalf("missing key %q in payload: %v", k, data)
|
||||
}
|
||||
if _, ok := v.([]interface{}); !ok {
|
||||
t.Fatalf("%s = %#v, want JSON array", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
summary *loginScopeSummary
|
||||
expectedPresent []string
|
||||
expectedAbsent []string
|
||||
}{
|
||||
{
|
||||
name: "mixed newly granted and already granted",
|
||||
summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send", "im:message:reply"},
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
AlreadyGranted: []string{"im:message:reply"},
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"登录成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"本次未授予 scopes: (空)",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all already granted",
|
||||
summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
AlreadyGranted: []string{"im:message:send"},
|
||||
Granted: []string{"im:message:send", "contact:user.base:readonly"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: (空)",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing scopes are shown",
|
||||
summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send", "im:message:reply"},
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"已有 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary)
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range tt.expectedPresent {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range tt.expectedAbsent {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLoginScopeSummary_WithMissingScopes(t *testing.T) {
|
||||
summary := buildLoginScopeSummary("im:message:send im:message:reply", "im:message:reply", "im:message:reply")
|
||||
if got := strings.Join(summary.NewlyGranted, " "); got != "" {
|
||||
t.Fatalf("NewlyGranted = %q, want empty", got)
|
||||
}
|
||||
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
|
||||
t.Fatalf("AlreadyGranted = %q", got)
|
||||
}
|
||||
if got := strings.Join(summary.Missing, " "); got != "im:message:send" {
|
||||
t.Fatalf("Missing = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "cli_test"},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 0,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthTokenV2,
|
||||
Body: map[string]interface{}{
|
||||
"access_token": "user-access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_in": 7200,
|
||||
"refresh_token_expires_in": 604800,
|
||||
"scope": "offline_access",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_user",
|
||||
"name": "tester",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
stored := larkauth.GetStoredToken("cli_test", "ou_user")
|
||||
if stored == nil {
|
||||
t.Fatal("expected token to be stored when authorization succeeds with missing scopes")
|
||||
}
|
||||
if stored.Scope != "offline_access" {
|
||||
t.Fatalf("stored scope = %q", stored.Scope)
|
||||
}
|
||||
cfg, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(cfg.Apps) != 1 || len(cfg.Apps[0].Users) != 1 {
|
||||
t.Fatalf("unexpected users in config: %#v", cfg.Apps)
|
||||
}
|
||||
if cfg.Apps[0].Users[0].UserOpenId != "ou_user" {
|
||||
t.Fatalf("stored user open id = %q", cfg.Apps[0].Users[0].UserOpenId)
|
||||
}
|
||||
if cfg.Apps[0].Users[0].UserName != "tester" {
|
||||
t.Fatalf("stored user name = %q", cfg.Apps[0].Users[0].UserName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "cli_test"},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 0,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthTokenV2,
|
||||
Body: map[string]interface{}{
|
||||
"access_token": "user-access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_in": 7200,
|
||||
"refresh_token_expires_in": 604800,
|
||||
"scope": "im:message:send offline_access",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_user",
|
||||
"name": "tester",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
NoWait: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("no-wait authLoginRun() error = %v", err)
|
||||
}
|
||||
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "im:message:send" {
|
||||
t.Fatalf("loadLoginRequestedScope() = (%q, %v), want requested scope", got, err)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
|
||||
err = authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
DeviceCode: "device-code",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("device-code authLoginRun() error = %v", err)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
|
||||
t.Fatalf("loadLoginRequestedScope() after cleanup = (%q, %v), want empty", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScopes(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
writeLoginSuccess(&LoginOptions{}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
Granted: []string{"im:message:send"},
|
||||
})
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"Login successful! User: tester (ou_user)",
|
||||
"Requested scopes: im:message:send",
|
||||
"Newly granted scopes: im:message:send",
|
||||
"Not granted scopes: (none)",
|
||||
"Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
if err := saveLoginRequestedScope("device-code", "im:message:send"); err != nil {
|
||||
t.Fatalf("saveLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
|
||||
original := pollDeviceToken
|
||||
t.Cleanup(func() { pollDeviceToken = original })
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
DeviceCode: "device-code",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil token")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "authorization succeeded but no token returned") {
|
||||
t.Fatalf("error = %v, want nil token error", err)
|
||||
}
|
||||
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
|
||||
t.Fatalf("loadLoginRequestedScope() after nil token = (%q, %v), want empty", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.Out = failWriter{}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 5,
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
NoWait: true,
|
||||
JSON: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to write JSON output") {
|
||||
t.Fatalf("error = %v, want JSON write failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.Out = failWriter{}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 5,
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: ctx,
|
||||
Scope: "im:message:send",
|
||||
JSON: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to write JSON output") {
|
||||
t.Fatalf("error = %v, want JSON write failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
|
||||
@@ -6,6 +6,8 @@ package config
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -21,6 +23,17 @@ func (n *noopConfigKeychain) Get(service, account string) (string, error) { retu
|
||||
func (n *noopConfigKeychain) Set(service, account, value string) error { return nil }
|
||||
func (n *noopConfigKeychain) Remove(service, account string) error { return nil }
|
||||
|
||||
type recordingConfigKeychain struct {
|
||||
removed []string
|
||||
}
|
||||
|
||||
func (r *recordingConfigKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (r *recordingConfigKeychain) Set(service, account, value string) error { return nil }
|
||||
func (r *recordingConfigKeychain) Remove(service, account string) error {
|
||||
r.removed = append(r.removed, service+":"+account)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret123\n")
|
||||
@@ -221,6 +234,66 @@ func TestConfigRemoveCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: "app-test",
|
||||
AppSecret: core.SecretInput{
|
||||
Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
|
||||
},
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
kc := &recordingConfigKeychain{}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Keychain = kc
|
||||
|
||||
// Make subsequent config saves fail while keeping the existing config readable.
|
||||
if err := os.Chmod(configDir, 0500); err != nil {
|
||||
t.Fatalf("Chmod(%s) error = %v", configDir, err)
|
||||
}
|
||||
defer os.Chmod(configDir, 0700)
|
||||
|
||||
err := configRemoveRun(&ConfigRemoveOptions{Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("expected save failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to save config") {
|
||||
t.Fatalf("error = %v, want failed to save config", err)
|
||||
}
|
||||
if len(kc.removed) != 0 {
|
||||
t.Fatalf("expected no keychain cleanup before successful save, got removals: %v", kc.removed)
|
||||
}
|
||||
|
||||
// Restore permissions and confirm the original config is still intact.
|
||||
if err := os.Chmod(configDir, 0700); err != nil {
|
||||
t.Fatalf("restore Chmod(%s) error = %v", configDir, err)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved == nil || len(saved.Apps) != 1 || saved.Apps[0].AppId != "app-test" {
|
||||
t.Fatalf("saved config = %#v, want original single app preserved", saved)
|
||||
}
|
||||
if got := saved.Apps[0].AppSecret.Ref; got == nil || got.ID != "appsecret:app-test" {
|
||||
t.Fatalf("saved app secret ref = %#v, want preserved keychain ref", got)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, "config.json")
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
t.Fatalf("expected existing config file to remain, stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
|
||||
@@ -44,19 +44,21 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
|
||||
return output.ErrValidation("not configured yet")
|
||||
}
|
||||
|
||||
// Clean up keychain entries for all apps
|
||||
for _, app := range config.Apps {
|
||||
core.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
for _, user := range app.Users {
|
||||
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
|
||||
}
|
||||
}
|
||||
|
||||
// Save empty config
|
||||
// Save empty config first. If this fails, keep secrets and tokens intact so the
|
||||
// existing config can still be retried instead of ending up half-removed.
|
||||
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
||||
if err := core.SaveMultiAppConfig(empty); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
// Clean up keychain entries for all apps after config is cleared.
|
||||
for _, app := range config.Apps {
|
||||
core.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
for _, user := range app.Users {
|
||||
_ = auth.RemoveStoredToken(app.AppId, user.UserOpenId)
|
||||
}
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Configuration removed")
|
||||
userCount := 0
|
||||
for _, app := range config.Apps {
|
||||
|
||||
@@ -78,7 +78,7 @@ AI AGENT SKILLS:
|
||||
npx skills add larksuite/cli -s lark-calendar -y
|
||||
npx skills add larksuite/cli -s lark-im -y
|
||||
|
||||
Learn more: https://github.com/larksuite/cli#install-ai-agent-skills
|
||||
Learn more: https://github.com/larksuite/cli#agent-skills
|
||||
|
||||
COMMUNITY:
|
||||
GitHub: https://github.com/larksuite/cli
|
||||
|
||||
@@ -187,3 +187,12 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||
}
|
||||
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -250,6 +249,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CheckError: checkErr,
|
||||
})
|
||||
}
|
||||
@@ -309,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), ¶ms); 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")
|
||||
@@ -364,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
|
||||
}
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
3
extension/credential/env/env_test.go
vendored
3
extension/credential/env/env_test.go
vendored
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package env
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
|
||||
40
extension/fileio/errors.go
Normal file
40
extension/fileio/errors.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrPathValidation indicates the path failed security validation
|
||||
// (traversal, absolute, control chars, symlink escape, etc.).
|
||||
var ErrPathValidation = errors.New("path validation failed")
|
||||
|
||||
// PathValidationError wraps a path validation error.
|
||||
// errors.Is(err, ErrPathValidation) returns true.
|
||||
// errors.Is(err, <original OS error>) also works via the chain.
|
||||
type PathValidationError struct {
|
||||
Err error // original error
|
||||
}
|
||||
|
||||
func (e *PathValidationError) Error() string { return e.Err.Error() }
|
||||
func (e *PathValidationError) Unwrap() []error {
|
||||
return []error{ErrPathValidation, e.Err}
|
||||
}
|
||||
|
||||
// MkdirError indicates parent directory creation failed.
|
||||
// Use errors.As(err, &fileio.MkdirError{}) to match.
|
||||
type MkdirError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *MkdirError) Error() string { return e.Err.Error() }
|
||||
func (e *MkdirError) Unwrap() error { return e.Err }
|
||||
|
||||
// WriteError indicates file write failed.
|
||||
// Use errors.As(err, &fileio.WriteError{}) to match.
|
||||
type WriteError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *WriteError) Error() string { return e.Err.Error() }
|
||||
func (e *WriteError) Unwrap() error { return e.Err }
|
||||
31
extension/fileio/registry.go
Normal file
31
extension/fileio/registry.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register registers a FileIO Provider.
|
||||
// Later registrations override earlier ones (last-write-wins).
|
||||
// Unlike credential.Register which appends to a chain (multiple credential
|
||||
// sources are tried in order), FileIO uses a single active provider because
|
||||
// only one file I/O backend is active at a time (local vs server mode).
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
73
extension/fileio/types.go
Normal file
73
extension/fileio/types.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// Provider creates FileIO instances.
|
||||
// Follows the same API style as extension/credential.Provider.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveFileIO(ctx context.Context) FileIO
|
||||
}
|
||||
|
||||
// FileIO abstracts file transfer operations for CLI commands.
|
||||
// The default implementation operates on the local filesystem with
|
||||
// path validation, directory creation, and atomic writes.
|
||||
// Inject a custom implementation via Factory.FileIOProvider to replace
|
||||
// file transfer behavior (e.g. streaming in server mode).
|
||||
type FileIO interface {
|
||||
// Open opens a file for reading (upload, attachment, template scenarios).
|
||||
// The default implementation validates the path via SafeInputPath.
|
||||
Open(name string) (File, error)
|
||||
|
||||
// Stat returns file metadata (size validation, existence checks).
|
||||
// The default implementation validates the path via SafeInputPath.
|
||||
// Use os.IsNotExist(err) to distinguish "file not found" from "invalid path".
|
||||
Stat(name string) (FileInfo, error)
|
||||
|
||||
// ResolvePath returns the validated, absolute path for the given output path.
|
||||
// The default implementation delegates to SafeOutputPath.
|
||||
// Use this to obtain the canonical saved path for user-facing output.
|
||||
ResolvePath(path string) (string, error)
|
||||
|
||||
// Save writes content to the target path and returns a SaveResult.
|
||||
// The default implementation validates via SafeOutputPath, creates
|
||||
// parent directories, and writes atomically.
|
||||
Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error)
|
||||
}
|
||||
|
||||
// FileInfo is a minimal subset of os.FileInfo covering actual CLI usage.
|
||||
// os.FileInfo satisfies this interface.
|
||||
type FileInfo interface {
|
||||
Size() int64
|
||||
IsDir() bool
|
||||
Mode() fs.FileMode
|
||||
}
|
||||
|
||||
// File is the interface returned by FileIO.Open.
|
||||
// It covers the subset of *os.File methods actually used by CLI commands.
|
||||
// *os.File satisfies this interface without adaptation.
|
||||
type File interface {
|
||||
io.Reader
|
||||
io.ReaderAt
|
||||
io.Closer
|
||||
}
|
||||
|
||||
// SaveResult holds the outcome of a Save operation.
|
||||
type SaveResult interface {
|
||||
Size() int64 // actual bytes written
|
||||
}
|
||||
|
||||
// SaveOptions carries metadata for Save.
|
||||
// The default (local) implementation ignores these fields;
|
||||
// server-mode implementations use them to construct streaming response frames.
|
||||
type SaveOptions struct {
|
||||
ContentType string // MIME type
|
||||
ContentLength int64 // content length; -1 if unknown
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
|
||||
45
internal/charcheck/charcheck.go
Normal file
45
internal/charcheck/charcheck.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package charcheck provides character-level security checks shared across
|
||||
// path validation (localfileio) and input validation (validate) packages.
|
||||
// Keeping these checks in one place ensures consistent detection of dangerous
|
||||
// Unicode and control characters throughout the codebase.
|
||||
package charcheck
|
||||
|
||||
import "fmt"
|
||||
|
||||
// RejectControlChars rejects C0 control characters (except \t and \n) and
|
||||
// dangerous Unicode characters (Bidi overrides, zero-width, line/paragraph
|
||||
// separators) that enable visual spoofing attacks.
|
||||
func RejectControlChars(value, flagName string) error {
|
||||
for _, r := range value {
|
||||
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) {
|
||||
return fmt.Errorf("%s contains invalid control characters", flagName)
|
||||
}
|
||||
if IsDangerousUnicode(r) {
|
||||
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDangerousUnicode identifies Unicode code points used for visual spoofing
|
||||
// attacks. These characters are invisible or alter text direction, allowing
|
||||
// attackers to make "report.exe" display as "report.txt" (Bidi override) or
|
||||
// insert hidden content (zero-width characters).
|
||||
func IsDangerousUnicode(r rune) bool {
|
||||
switch {
|
||||
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
|
||||
return true
|
||||
case r == 0xFEFF: // BOM / ZWNBSP
|
||||
return true
|
||||
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
|
||||
return true
|
||||
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
|
||||
return true
|
||||
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
68
internal/client/api_errors.go
Normal file
68
internal/client/api_errors.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard JSON body. If it returns a file, rerun with --output."
|
||||
|
||||
// WrapDoAPIError upgrades malformed JSON decode errors from the SDK into
|
||||
// actionable API errors for raw `lark-cli api` calls. All other failures
|
||||
// remain network errors.
|
||||
func WrapDoAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if isJSONDecodeError(err, false) {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
|
||||
}
|
||||
return output.ErrNetwork("API call failed: %v", err)
|
||||
}
|
||||
|
||||
// WrapJSONResponseParseError upgrades empty or malformed JSON response bodies
|
||||
// into API errors with hints instead of generic parse failures.
|
||||
func WrapJSONResponseParseError(err error, body []byte) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
"API returned an empty JSON response body", rawAPIJSONHint)
|
||||
}
|
||||
if isJSONDecodeError(err, true) {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
|
||||
}
|
||||
return output.ErrNetwork("API call failed: %v", err)
|
||||
}
|
||||
|
||||
func isJSONDecodeError(err error, allowEOF bool) bool {
|
||||
var syntaxErr *json.SyntaxError
|
||||
var unmarshalTypeErr *json.UnmarshalTypeError
|
||||
|
||||
if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) {
|
||||
return true
|
||||
}
|
||||
if allowEOF && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
|
||||
return true
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if allowEOF && strings.Contains(msg, "unexpected EOF") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(msg, "unexpected end of JSON input") ||
|
||||
strings.Contains(msg, "invalid character") ||
|
||||
strings.Contains(msg, "cannot unmarshal")
|
||||
}
|
||||
68
internal/client/api_errors_test.go
Normal file
68
internal/client/api_errors_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWrapDoAPIError_BareEOFIsNetworkError(t *testing.T) {
|
||||
err := WrapDoAPIError(io.EOF)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("expected ExitNetwork, got %d", exitErr.Code)
|
||||
}
|
||||
if strings.Contains(exitErr.Error(), "invalid JSON response") {
|
||||
t.Fatalf("unexpected JSON diagnostic for bare EOF: %q", exitErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
|
||||
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
|
||||
t.Fatalf("expected JSON diagnostic message, got %#v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
|
||||
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
|
||||
t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
@@ -6,18 +6,17 @@ package client
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"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/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// ── Response routing ──
|
||||
@@ -29,6 +28,7 @@ type ResponseOptions struct {
|
||||
JqExpr string // if set, apply jq filter instead of Format
|
||||
Out io.Writer // stdout
|
||||
ErrOut io.Writer // stderr
|
||||
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
|
||||
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
|
||||
CheckError func(interface{}) error
|
||||
}
|
||||
@@ -55,13 +55,13 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
if IsJSONContentType(ct) || ct == "" {
|
||||
result, err := ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("API call failed: %v", err)
|
||||
return WrapJSONResponseParseError(err, resp.RawBody)
|
||||
}
|
||||
if apiErr := check(result); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
if opts.OutputPath != "" {
|
||||
return saveAndPrint(resp, opts.OutputPath, opts.Out)
|
||||
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
|
||||
}
|
||||
if opts.JqExpr != "" {
|
||||
return output.JqFilter(opts.Out, result, opts.JqExpr)
|
||||
@@ -75,11 +75,11 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct)
|
||||
}
|
||||
if opts.OutputPath != "" {
|
||||
return saveAndPrint(resp, opts.OutputPath, opts.Out)
|
||||
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
|
||||
}
|
||||
|
||||
// No --output: auto-save with derived filename.
|
||||
meta, err := SaveResponse(resp, ResolveFilename(resp))
|
||||
meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp))
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
|
||||
}
|
||||
@@ -88,8 +88,8 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAndPrint(resp *larkcore.ApiResp, path string, w io.Writer) error {
|
||||
meta, err := SaveResponse(resp, path)
|
||||
func saveAndPrint(fio fileio.FileIO, resp *larkcore.ApiResp, path string, w io.Writer) error {
|
||||
meta, err := SaveResponse(fio, resp, path)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(resp.RawBody))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("response parse error: %v (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
|
||||
return nil, fmt.Errorf("response parse error: %w (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -119,23 +119,34 @@ func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) {
|
||||
// ── File saving ──
|
||||
|
||||
// SaveResponse writes an API response body to the given outputPath and returns metadata.
|
||||
func SaveResponse(resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) {
|
||||
safePath, err := validate.SafeOutputPath(outputPath)
|
||||
// It delegates to FileIO.Save for path validation and atomic write; fio must not be nil.
|
||||
func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) {
|
||||
result, err := fio.Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: int64(len(resp.RawBody)),
|
||||
}, bytes.NewReader(resp.RawBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unsafe output path: %s", err)
|
||||
var me *fileio.MkdirError
|
||||
var we *fileio.WriteError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return nil, fmt.Errorf("unsafe output path: %s", err)
|
||||
case errors.As(err, &me):
|
||||
return nil, fmt.Errorf("create directory: %s", err)
|
||||
case errors.As(err, &we):
|
||||
return nil, fmt.Errorf("cannot write file: %s", err)
|
||||
default:
|
||||
return nil, fmt.Errorf("cannot write file: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
return nil, fmt.Errorf("create directory: %s", err)
|
||||
resolvedPath, err := fio.ResolvePath(outputPath)
|
||||
if err != nil || resolvedPath == "" {
|
||||
resolvedPath = outputPath
|
||||
}
|
||||
|
||||
if err := validate.AtomicWrite(safePath, resp.RawBody, 0644); err != nil {
|
||||
return nil, fmt.Errorf("cannot write file: %s", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"saved_path": safePath,
|
||||
"size_bytes": len(resp.RawBody),
|
||||
"saved_path": resolvedPath,
|
||||
"size_bytes": result.Size(),
|
||||
"content_type": resp.Header.Get("Content-Type"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package client
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
func newApiResp(body []byte, headers map[string]string) *larkcore.ApiResp {
|
||||
@@ -75,6 +77,17 @@ func TestParseJSONResponse_Invalid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONResponse_EmptyBody_WrapsEOF(t *testing.T) {
|
||||
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
|
||||
_, err := ParseJSONResponse(resp)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty body")
|
||||
}
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("expected wrapped io.EOF, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -150,11 +163,11 @@ func TestSaveResponse(t *testing.T) {
|
||||
body := []byte("hello binary data")
|
||||
resp := newApiResp(body, map[string]string{"Content-Type": "application/octet-stream"})
|
||||
|
||||
meta, err := SaveResponse(resp, "test_output.bin")
|
||||
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "test_output.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveResponse failed: %v", err)
|
||||
}
|
||||
if meta["size_bytes"] != len(body) {
|
||||
if meta["size_bytes"] != int64(len(body)) {
|
||||
t.Errorf("expected size_bytes=%d, got %v", len(body), meta["size_bytes"])
|
||||
}
|
||||
|
||||
@@ -176,7 +189,7 @@ func TestSaveResponse_CreatesDir(t *testing.T) {
|
||||
|
||||
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
|
||||
|
||||
meta, err := SaveResponse(resp, filepath.Join("sub", "deep", "out.bin"))
|
||||
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, filepath.Join("sub", "deep", "out.bin"))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveResponse with nested dir failed: %v", err)
|
||||
}
|
||||
@@ -195,6 +208,7 @@ func TestHandleResponse_JSON(t *testing.T) {
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse failed: %v", err)
|
||||
@@ -213,12 +227,44 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for non-zero code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_EmptyJSONBody_ShowsDiagnostic(t *testing.T) {
|
||||
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty JSON body")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected detail on exit error")
|
||||
}
|
||||
if exitErr.Detail.Message != "API returned an empty JSON response body" {
|
||||
t.Fatalf("unexpected message: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--output") {
|
||||
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
@@ -232,6 +278,7 @@ func TestHandleResponse_BinaryAutoSave(t *testing.T) {
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse binary failed: %v", err)
|
||||
@@ -255,6 +302,7 @@ func TestHandleResponse_BinaryWithOutput(t *testing.T) {
|
||||
OutputPath: "out.png",
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse with output path failed: %v", err)
|
||||
@@ -269,7 +317,7 @@ func TestHandleResponse_NonJSONError_404(t *testing.T) {
|
||||
resp := newApiRespWithStatus(404, []byte("404 page not found"), map[string]string{"Content-Type": "text/plain"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404 text/plain")
|
||||
}
|
||||
@@ -287,7 +335,7 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) {
|
||||
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), map[string]string{"Content-Type": "text/html"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 502 text/html")
|
||||
}
|
||||
@@ -310,7 +358,7 @@ func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
|
||||
resp := newApiRespWithStatus(200, []byte("plain text file content"), map[string]string{"Content-Type": "text/plain"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for 200 text/plain, got: %v", err)
|
||||
}
|
||||
@@ -336,12 +384,53 @@ func TestHandleResponse_BinaryWithJq_RejectsNonJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResponse_RejectsPathTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
defer os.Chdir(origWd)
|
||||
|
||||
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
|
||||
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "../../evil.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for path traversal")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsafe output path") {
|
||||
t.Errorf("expected 'unsafe output path' wrapper, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResponse_RejectsAbsolutePath(t *testing.T) {
|
||||
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
|
||||
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "/tmp/evil.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResponse_MetadataContainsAbsolutePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
defer os.Chdir(origWd)
|
||||
|
||||
resp := newApiResp([]byte("x"), map[string]string{"Content-Type": "text/plain"})
|
||||
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "rel.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveResponse failed: %v", err)
|
||||
}
|
||||
savedPath, _ := meta["saved_path"].(string)
|
||||
if !filepath.IsAbs(savedPath) {
|
||||
t.Errorf("saved_path should be absolute, got %q", savedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) {
|
||||
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
|
||||
resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403 JSON with non-zero code")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
@@ -40,6 +41,17 @@ type Factory struct {
|
||||
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
|
||||
|
||||
Credential *credential.CredentialProvider
|
||||
|
||||
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
// The provider controls whether the returned instance is fresh or cached.
|
||||
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
||||
if f == nil || f.FileIOProvider == nil {
|
||||
return nil
|
||||
}
|
||||
return f.FileIOProvider.ResolveFileIO(ctx)
|
||||
}
|
||||
|
||||
// ResolveAs returns the effective identity type.
|
||||
|
||||
@@ -17,12 +17,14 @@ import (
|
||||
"golang.org/x/term"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
||||
)
|
||||
|
||||
// NewDefault creates a production Factory with cached closures.
|
||||
@@ -44,6 +46,9 @@ func NewDefault(inv InvocationContext) *Factory {
|
||||
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
}
|
||||
|
||||
// Phase 0: FileIO provider (no dependency)
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc()
|
||||
|
||||
|
||||
@@ -11,13 +11,26 @@ import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
type countingFileIOProvider struct {
|
||||
resolveCalls int
|
||||
}
|
||||
|
||||
func (p *countingFileIOProvider) Name() string { return "counting" }
|
||||
|
||||
func (p *countingFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO {
|
||||
p.resolveCalls++
|
||||
return &localfileio.LocalFileIO{}
|
||||
}
|
||||
|
||||
func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
@@ -198,6 +211,28 @@ func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.T) {
|
||||
prev := fileio.GetProvider()
|
||||
provider := &countingFileIOProvider{}
|
||||
fileio.Register(provider)
|
||||
t.Cleanup(func() { fileio.Register(prev) })
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
if f.FileIOProvider != provider {
|
||||
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
|
||||
}
|
||||
if provider.resolveCalls != 0 {
|
||||
t.Fatalf("ResolveFileIO() calls after NewDefault() = %d, want 0", provider.resolveCalls)
|
||||
}
|
||||
|
||||
if got := f.ResolveFileIO(context.Background()); got == nil {
|
||||
t.Fatal("ResolveFileIO() = nil, want non-nil")
|
||||
}
|
||||
if provider.resolveCalls != 1 {
|
||||
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type stubTransportProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
46
internal/cmdutil/resolve.go
Normal file
46
internal/cmdutil/resolve.go
Normal 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
|
||||
}
|
||||
189
internal/cmdutil/resolve_test.go
Normal file
189
internal/cmdutil/resolve_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,17 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// noopKeychain is a no-op KeychainAccess for tests that don't need keychain.
|
||||
@@ -62,12 +65,13 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
|
||||
)
|
||||
|
||||
f := &Factory{
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
HttpClient: func() (*http.Client, error) { return mockClient, nil },
|
||||
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
|
||||
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
|
||||
Keychain: &noopKeychain{},
|
||||
Credential: testCred,
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
HttpClient: func() (*http.Client, error) { return mockClient, nil },
|
||||
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
|
||||
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
|
||||
Keychain: &noopKeychain{},
|
||||
Credential: testCred,
|
||||
FileIOProvider: fileio.GetProvider(),
|
||||
}
|
||||
return f, stdoutBuf, stderrBuf, reg
|
||||
}
|
||||
@@ -83,6 +87,23 @@ func (a *testDefaultAcct) ResolveAccount(ctx context.Context) (*credential.Accou
|
||||
return credential.AccountFromCliConfig(a.config), nil
|
||||
}
|
||||
|
||||
// TestChdir changes the working directory to dir for the duration of the test.
|
||||
// The original directory is restored via t.Cleanup.
|
||||
// This enables tests to use LocalFileIO (which resolves relative paths under cwd)
|
||||
// with temporary directories, keeping test artifacts out of the source tree.
|
||||
// Not compatible with t.Parallel() — os.Chdir is process-wide.
|
||||
func TestChdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
orig, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil { //nolint:forbidigo // no vfs.Chdir yet; test-only, process-wide chdir
|
||||
t.Fatalf("Chdir(%s): %v", dir, err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) }) //nolint:forbidigo // matching restore
|
||||
}
|
||||
|
||||
type testDefaultToken struct{}
|
||||
|
||||
func (t *testDefaultToken) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
59
internal/core/secret_resolve_test.go
Normal file
59
internal/core/secret_resolve_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential_test
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
@@ -9,6 +12,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -21,6 +25,13 @@ var (
|
||||
)
|
||||
|
||||
func authLogDir() string {
|
||||
if dir := os.Getenv("LARKSUITE_CLI_LOG_DIR"); dir != "" {
|
||||
safeDir, err := validate.SafeEnvDirPath(dir, "LARKSUITE_CLI_LOG_DIR")
|
||||
if err == nil {
|
||||
return safeDir
|
||||
}
|
||||
}
|
||||
|
||||
if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" {
|
||||
return filepath.Join(dir, "logs")
|
||||
}
|
||||
|
||||
38
internal/keychain/auth_log_test.go
Normal file
38
internal/keychain/auth_log_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAuthLogDir_UsesValidatedLogDirEnv verifies that a valid absolute
|
||||
// LARKSUITE_CLI_LOG_DIR is normalized and used as the auth log directory.
|
||||
func TestAuthLogDir_UsesValidatedLogDirEnv(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base, _ = filepath.EvalSymlinks(base)
|
||||
t.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(base, "logs", "..", "auth"))
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "")
|
||||
|
||||
got := authLogDir()
|
||||
want := filepath.Join(base, "auth")
|
||||
if got != want {
|
||||
t.Fatalf("authLogDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthLogDir_InvalidLogDirFallsBackToConfigDir verifies that an invalid
|
||||
// LARKSUITE_CLI_LOG_DIR falls back to LARKSUITE_CLI_CONFIG_DIR/logs.
|
||||
func TestAuthLogDir_InvalidLogDirFallsBackToConfigDir(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_LOG_DIR", "relative-logs")
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
got := authLogDir()
|
||||
want := filepath.Join(configDir, "logs")
|
||||
if got != want {
|
||||
t.Fatalf("authLogDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package keychain
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"regexp"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,12 @@ const tagBytes = 16
|
||||
|
||||
// StorageDir returns the directory where encrypted files are stored.
|
||||
func StorageDir(service string) string {
|
||||
if dir := os.Getenv("LARKSUITE_CLI_DATA_DIR"); dir != "" {
|
||||
safeDir, err := validate.SafeEnvDirPath(dir, "LARKSUITE_CLI_DATA_DIR")
|
||||
if err == nil {
|
||||
return filepath.Join(safeDir, service)
|
||||
}
|
||||
}
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
// If home is missing, fallback to relative path and print warning.
|
||||
|
||||
40
internal/keychain/keychain_other_test.go
Normal file
40
internal/keychain/keychain_other_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStorageDir_UsesValidatedDataDirEnv verifies that a valid absolute
|
||||
// LARKSUITE_CLI_DATA_DIR is normalized and still preserves service isolation.
|
||||
func TestStorageDir_UsesValidatedDataDirEnv(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base, _ = filepath.EvalSymlinks(base)
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", filepath.Join(base, "data", "..", "store"))
|
||||
|
||||
got := StorageDir("svc")
|
||||
want := filepath.Join(base, "store", "svc")
|
||||
if got != want {
|
||||
t.Fatalf("StorageDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStorageDir_InvalidDataDirFallsBackToDefault verifies that an invalid
|
||||
// LARKSUITE_CLI_DATA_DIR falls back to the default per-service storage path.
|
||||
func TestStorageDir_InvalidDataDirFallsBackToDefault(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
home, _ = filepath.EvalSymlinks(home)
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", "relative-data")
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
got := StorageDir("svc")
|
||||
want := filepath.Join(home, ".local", "share", "svc")
|
||||
if got != want {
|
||||
t.Fatalf("StorageDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -4,74 +4,20 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// AtomicWrite writes data to path atomically by creating a temp file in the
|
||||
// same directory, writing and fsyncing the data, then renaming over the target.
|
||||
// It replaces os.WriteFile for all config and download file writes.
|
||||
//
|
||||
// os.WriteFile truncates the target before writing, so a process kill (CI timeout,
|
||||
// OOM, Ctrl+C) between truncate and completion leaves the file empty or partial.
|
||||
// AtomicWrite avoids this: on any failure the temp file is cleaned up and the
|
||||
// original file remains untouched.
|
||||
// AtomicWrite writes data to path atomically.
|
||||
// Delegates to localfileio.AtomicWrite.
|
||||
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
|
||||
return atomicWrite(path, perm, func(tmp *os.File) error {
|
||||
_, err := tmp.Write(data)
|
||||
return err
|
||||
})
|
||||
return localfileio.AtomicWrite(path, data, perm)
|
||||
}
|
||||
|
||||
// AtomicWriteFromReader atomically copies reader contents into path.
|
||||
// Delegates to localfileio.AtomicWriteFromReader.
|
||||
func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) {
|
||||
var copied int64
|
||||
err := atomicWrite(path, perm, func(tmp *os.File) error {
|
||||
n, err := io.Copy(tmp, reader)
|
||||
copied = n
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return copied, nil
|
||||
}
|
||||
|
||||
func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
tmp.Close()
|
||||
vfs.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFn(tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Rename(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
return localfileio.AtomicWriteFromReader(path, reader, perm)
|
||||
}
|
||||
|
||||
@@ -6,25 +6,17 @@ package validate
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
)
|
||||
|
||||
// RejectControlChars rejects C0 control characters (except \t and \n) and
|
||||
// dangerous Unicode characters from user input.
|
||||
//
|
||||
// Control characters cause subtle security issues: null bytes truncate strings
|
||||
// at the C layer, \r\n enables HTTP header injection
|
||||
// Unicode characters allow visual spoofing (e.g. making "report.exe" display
|
||||
// as "report.txt").
|
||||
// Delegates to charcheck.RejectControlChars — the single source of truth
|
||||
// for character-level security checks.
|
||||
func RejectControlChars(value, flagName string) error {
|
||||
for _, r := range value {
|
||||
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) {
|
||||
return fmt.Errorf("%s contains invalid control characters", flagName)
|
||||
}
|
||||
if isDangerousUnicode(r) {
|
||||
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return charcheck.RejectControlChars(value, flagName)
|
||||
}
|
||||
|
||||
// RejectCRLF rejects strings containing carriage return (\r) or line feed (\n).
|
||||
@@ -48,23 +40,3 @@ func StripQueryFragment(path string) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// isDangerousUnicode identifies Unicode code points used for visual spoofing attacks.
|
||||
// These characters are invisible or alter text direction, allowing attackers to make
|
||||
// "report.exe" display as "report.txt" (Bidi override) or insert hidden content
|
||||
// (zero-width characters).
|
||||
func isDangerousUnicode(r rune) bool {
|
||||
switch {
|
||||
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
|
||||
return true
|
||||
case r == 0xFEFF: // BOM / ZWNBSP
|
||||
return true
|
||||
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
|
||||
return true
|
||||
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
|
||||
return true
|
||||
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3,127 +3,28 @@
|
||||
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
import "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// SafeOutputPath validates a download/export target path for --output flags.
|
||||
// It rejects absolute paths, resolves symlinks to their real location, and
|
||||
// verifies the canonical result is still under the current working directory.
|
||||
// This prevents an AI Agent from being tricked into writing files outside the
|
||||
// working directory (e.g. "../../.ssh/authorized_keys") or following symlinks
|
||||
// to sensitive locations.
|
||||
//
|
||||
// The returned absolute path MUST be used for all subsequent I/O to prevent
|
||||
// time-of-check-to-time-of-use (TOCTOU) race conditions.
|
||||
// SafeOutputPath validates a download/export target path.
|
||||
// Delegates to localfileio.SafeOutputPath.
|
||||
func SafeOutputPath(path string) (string, error) {
|
||||
return safePath(path, "--output")
|
||||
return localfileio.SafeOutputPath(path)
|
||||
}
|
||||
|
||||
// SafeInputPath validates an upload/read source path for --file flags.
|
||||
// It applies the same rules as SafeOutputPath — rejecting absolute paths,
|
||||
// resolving symlinks, and enforcing working directory containment — to prevent an AI Agent
|
||||
// from being tricked into reading sensitive files like /etc/passwd.
|
||||
// SafeInputPath validates an upload/read source path.
|
||||
// Delegates to localfileio.SafeInputPath.
|
||||
func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
return localfileio.SafeInputPath(path)
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// Delegates to localfileio.SafeEnvDirPath.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
return localfileio.SafeEnvDirPath(path, envName)
|
||||
}
|
||||
|
||||
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||
// Empty values and http/https URLs are returned unchanged without validation,
|
||||
// allowing the caller to handle non-path inputs (e.g. API keys, URLs) upstream.
|
||||
// For all other values, SafeInputPath rules apply.
|
||||
// The original relative path is returned unchanged (not resolved to absolute) so
|
||||
// upload helpers can re-validate at the actual I/O point via SafeUploadPath.
|
||||
// Delegates to localfileio.SafeLocalFlagPath.
|
||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
|
||||
return value, nil
|
||||
}
|
||||
if _, err := SafeInputPath(value); err != nil {
|
||||
return "", fmt.Errorf("%s: %v", flagName, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// safePath is the shared implementation for SafeOutputPath and SafeInputPath.
|
||||
func safePath(raw, flagName string) (string, error) {
|
||||
if err := RejectControlChars(raw, flagName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path := filepath.Clean(raw)
|
||||
|
||||
if filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
}
|
||||
|
||||
cwd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
resolved := filepath.Join(cwd, path)
|
||||
|
||||
// Resolve symlinks: for existing paths, follow to real location;
|
||||
// for non-existing paths, walk up to the nearest existing ancestor,
|
||||
// resolve its symlinks, and re-attach the remaining tail segments.
|
||||
// This prevents TOCTOU attacks where a non-existent intermediate
|
||||
// directory is replaced with a symlink between check and use.
|
||||
if _, err := vfs.Lstat(resolved); err == nil {
|
||||
resolved, err = filepath.EvalSymlinks(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
} else {
|
||||
resolved, err = resolveNearestAncestor(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
canonicalCwd, _ := filepath.EvalSymlinks(cwd)
|
||||
if !isUnderDir(resolved, canonicalCwd) {
|
||||
return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw)
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// resolveNearestAncestor walks up from path until it finds an existing
|
||||
// ancestor, resolves that ancestor's symlinks, and re-joins the tail.
|
||||
// This ensures even deeply nested non-existent paths are anchored to a
|
||||
// real filesystem location, closing the TOCTOU symlink gap.
|
||||
func resolveNearestAncestor(path string) (string, error) {
|
||||
var tail []string
|
||||
cur := path
|
||||
for {
|
||||
if _, err := vfs.Lstat(cur); err == nil {
|
||||
real, err := filepath.EvalSymlinks(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := append([]string{real}, tail...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
// Reached filesystem root without finding an existing ancestor;
|
||||
// return path as-is and let the containment check reject it.
|
||||
parts := append([]string{cur}, tail...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
tail = append([]string{filepath.Base(cur)}, tail...)
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
// isUnderDir checks whether child is under parent directory.
|
||||
func isUnderDir(child, parent string) bool {
|
||||
rel, err := filepath.Rel(parent, child)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
|
||||
return localfileio.SafeLocalFlagPath(flagName, value)
|
||||
}
|
||||
|
||||
@@ -283,3 +283,30 @@ func TestSafeInputPath_ErrorMessageContainsCorrectFlagName(t *testing.T) {
|
||||
t.Errorf("error should mention --output, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeEnvDirPath_RequiresAbsolutePath verifies that environment-provided
|
||||
// directory paths must be absolute.
|
||||
func TestSafeEnvDirPath_RequiresAbsolutePath(t *testing.T) {
|
||||
_, err := SafeEnvDirPath("logs", "LARKSUITE_CLI_LOG_DIR")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for relative path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "LARKSUITE_CLI_LOG_DIR") {
|
||||
t.Fatalf("error should mention env name, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeEnvDirPath_ReturnsNormalizedAbsolutePath verifies that a valid
|
||||
// absolute environment directory is cleaned and resolved to its canonical path.
|
||||
func TestSafeEnvDirPath_ReturnsNormalizedAbsolutePath(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base, _ = filepath.EvalSymlinks(base)
|
||||
got, err := SafeEnvDirPath(filepath.Join(base, "logs", "..", "auth"), "LARKSUITE_CLI_LOG_DIR")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := filepath.Join(base, "auth")
|
||||
if got != want {
|
||||
t.Fatalf("SafeEnvDirPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
)
|
||||
|
||||
// unsafeResourceChars matches URL-special characters, control characters,
|
||||
@@ -35,7 +37,7 @@ func ResourceName(name, flagName string) error {
|
||||
return fmt.Errorf("%s contains invalid characters", flagName)
|
||||
}
|
||||
for _, r := range name {
|
||||
if isDangerousUnicode(r) {
|
||||
if charcheck.IsDangerousUnicode(r) {
|
||||
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ package validate
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
)
|
||||
|
||||
// ansiEscape matches ANSI CSI sequences (ESC[ ... letter) and OSC sequences (ESC] ... BEL).
|
||||
@@ -34,7 +36,7 @@ func SanitizeForTerminal(text string) string {
|
||||
b.WriteRune(r)
|
||||
case r < 0x20 || r == 0x7f:
|
||||
continue
|
||||
case isDangerousUnicode(r):
|
||||
case charcheck.IsDangerousUnicode(r):
|
||||
continue
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
|
||||
@@ -5,6 +5,8 @@ package validate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
)
|
||||
|
||||
func TestSanitizeForTerminal_StripsEscapesAndDangerousChars(t *testing.T) {
|
||||
@@ -74,16 +76,16 @@ func TestIsDangerousUnicode_IdentifiesAllDangerousRanges(t *testing.T) {
|
||||
0x2066, 0x2067, 0x2068, 0x2069, // isolates
|
||||
}
|
||||
for _, r := range dangerous {
|
||||
if !isDangerousUnicode(r) {
|
||||
t.Errorf("isDangerousUnicode(%U) = false, want true", r)
|
||||
if !charcheck.IsDangerousUnicode(r) {
|
||||
t.Errorf("charcheck.IsDangerousUnicode(%U) = false, want true", r)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GIVEN: safe Unicode code points → THEN: returns false ──
|
||||
safe := []rune{'A', '中', '!', ' ', '\t', '\n', 0x200A, 0x2070}
|
||||
for _, r := range safe {
|
||||
if isDangerousUnicode(r) {
|
||||
t.Errorf("isDangerousUnicode(%U) = true, want false", r)
|
||||
if charcheck.IsDangerousUnicode(r) {
|
||||
t.Errorf("charcheck.IsDangerousUnicode(%U) = true, want false", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vfs
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vfs
|
||||
|
||||
import (
|
||||
|
||||
74
internal/vfs/localfileio/atomicwrite.go
Normal file
74
internal/vfs/localfileio/atomicwrite.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// AtomicWrite writes data to path atomically via temp file + rename.
|
||||
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
|
||||
return atomicWrite(path, perm, func(tmp *os.File) error {
|
||||
_, err := tmp.Write(data)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// AtomicWriteFromReader atomically copies reader contents into path.
|
||||
func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) {
|
||||
var copied int64
|
||||
err := atomicWrite(path, perm, func(tmp *os.File) error {
|
||||
n, err := io.Copy(tmp, reader)
|
||||
copied = n
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return copied, nil
|
||||
}
|
||||
|
||||
func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
|
||||
closed := false
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
if !closed {
|
||||
tmp.Close()
|
||||
}
|
||||
vfs.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFn(tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
closed = true
|
||||
if err := vfs.Rename(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
146
internal/vfs/localfileio/atomicwrite_test.go
Normal file
146
internal/vfs/localfileio/atomicwrite_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAtomicWrite_WritesContentAndPermissionCorrectly(t *testing.T) {
|
||||
// GIVEN: a target path in a temp directory
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.json")
|
||||
data := []byte(`{"key":"value"}`)
|
||||
|
||||
// WHEN: AtomicWrite writes data with 0644 permission
|
||||
if err := AtomicWrite(path, data, 0644); err != nil {
|
||||
t.Fatalf("AtomicWrite failed: %v", err)
|
||||
}
|
||||
|
||||
// THEN: file content matches exactly
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
if string(got) != string(data) {
|
||||
t.Errorf("content = %q, want %q", got, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_SetsRestrictivePermission(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission test not reliable on Windows")
|
||||
}
|
||||
|
||||
// GIVEN: a target path
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "secret.json")
|
||||
|
||||
// WHEN: AtomicWrite writes with 0600 permission
|
||||
if err := AtomicWrite(path, []byte("secret"), 0600); err != nil {
|
||||
t.Fatalf("AtomicWrite failed: %v", err)
|
||||
}
|
||||
|
||||
// THEN: file permission is exactly 0600 (owner read-write only)
|
||||
info, _ := os.Stat(path)
|
||||
if perm := info.Mode().Perm(); perm != 0600 {
|
||||
t.Errorf("permission = %04o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_OverwritesExistingFile(t *testing.T) {
|
||||
// GIVEN: an existing file with old content
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.json")
|
||||
AtomicWrite(path, []byte("old"), 0644)
|
||||
|
||||
// WHEN: AtomicWrite overwrites with new content
|
||||
if err := AtomicWrite(path, []byte("new"), 0644); err != nil {
|
||||
t.Fatalf("second write failed: %v", err)
|
||||
}
|
||||
|
||||
// THEN: file contains new content
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != "new" {
|
||||
t.Errorf("content = %q, want %q", got, "new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_LeavesNoResidualTempFileOnError(t *testing.T) {
|
||||
// GIVEN: a target path in a non-existent nested directory
|
||||
path := filepath.Join(t.TempDir(), "nonexistent", "subdir", "file.txt")
|
||||
|
||||
// WHEN: AtomicWrite fails (parent directory doesn't exist)
|
||||
err := AtomicWrite(path, []byte("data"), 0644)
|
||||
|
||||
// THEN: the write fails
|
||||
if err == nil {
|
||||
t.Fatal("expected error writing to nonexistent dir")
|
||||
}
|
||||
|
||||
// THEN: no .tmp files are left behind
|
||||
parentDir := filepath.Dir(filepath.Dir(path))
|
||||
entries, _ := os.ReadDir(parentDir)
|
||||
for _, e := range entries {
|
||||
if filepath.Ext(e.Name()) == ".tmp" {
|
||||
t.Errorf("residual temp file found: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_PreservesOriginalFileOnFailure(t *testing.T) {
|
||||
// GIVEN: an existing file with known content
|
||||
dir := t.TempDir()
|
||||
original := []byte("original content")
|
||||
path := filepath.Join(dir, "file.json")
|
||||
if err := AtomicWrite(path, original, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// WHEN: AtomicWrite targets a non-existent directory (guaranteed to fail even as root)
|
||||
badPath := filepath.Join(dir, "no", "such", "dir", "file.json")
|
||||
err := AtomicWrite(badPath, []byte("new"), 0644)
|
||||
|
||||
// THEN: write fails
|
||||
if err == nil {
|
||||
t.Fatal("expected error writing to non-existent dir")
|
||||
}
|
||||
|
||||
// THEN: the original file at the valid path is untouched
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != string(original) {
|
||||
t.Errorf("original file corrupted: got %q, want %q", got, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_HandlesCorrectlyUnderConcurrentWrites(t *testing.T) {
|
||||
// GIVEN: a target file that will be written by 20 concurrent goroutines
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "concurrent.json")
|
||||
|
||||
// WHEN: 20 goroutines write simultaneously
|
||||
var wg sync.WaitGroup
|
||||
for i := range 20 {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
data := []byte(`{"n":` + string(rune('0'+n%10)) + `}`)
|
||||
AtomicWrite(path, data, 0644)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// THEN: file exists and is valid (not corrupted by interleaved writes)
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Error("file is empty after concurrent writes")
|
||||
}
|
||||
}
|
||||
81
internal/vfs/localfileio/localfileio.go
Normal file
81
internal/vfs/localfileio/localfileio.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// Provider is the default fileio.Provider backed by the local filesystem.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "local" }
|
||||
|
||||
func (p *Provider) ResolveFileIO(_ context.Context) fileio.FileIO {
|
||||
return &LocalFileIO{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
fileio.Register(&Provider{})
|
||||
}
|
||||
|
||||
// LocalFileIO implements fileio.FileIO using the local filesystem.
|
||||
// Path validation (SafeInputPath/SafeOutputPath), directory creation,
|
||||
// and atomic writes are handled internally.
|
||||
type LocalFileIO struct{}
|
||||
|
||||
// Open opens a local file for reading after validating the path.
|
||||
func (l *LocalFileIO) Open(name string) (fileio.File, error) {
|
||||
safePath, err := SafeInputPath(name)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return vfs.Open(safePath)
|
||||
}
|
||||
|
||||
// Stat returns file metadata after validating the path.
|
||||
func (l *LocalFileIO) Stat(name string) (fileio.FileInfo, error) {
|
||||
safePath, err := SafeInputPath(name)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return vfs.Stat(safePath)
|
||||
}
|
||||
|
||||
// saveResult implements fileio.SaveResult.
|
||||
type saveResult struct{ size int64 }
|
||||
|
||||
func (r *saveResult) Size() int64 { return r.size }
|
||||
|
||||
// ResolvePath returns the validated absolute path for the given output path.
|
||||
func (l *LocalFileIO) ResolvePath(path string) (string, error) {
|
||||
resolved, err := SafeOutputPath(path)
|
||||
if err != nil {
|
||||
return "", &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// Save writes body to path atomically after validating the output path.
|
||||
// Parent directories are created as needed. The body is streamed directly
|
||||
// to a temp file and renamed, avoiding full in-memory buffering.
|
||||
func (l *LocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) {
|
||||
safePath, err := SafeOutputPath(path)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
return nil, &fileio.MkdirError{Err: err}
|
||||
}
|
||||
n, err := AtomicWriteFromReader(safePath, body, 0600)
|
||||
if err != nil {
|
||||
return nil, &fileio.WriteError{Err: err}
|
||||
}
|
||||
return &saveResult{size: n}, nil
|
||||
}
|
||||
306
internal/vfs/localfileio/localfileio_test.go
Normal file
306
internal/vfs/localfileio/localfileio_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// testChdir temporarily changes the working directory for a test.
|
||||
// Not compatible with t.Parallel().
|
||||
func testChdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
}
|
||||
|
||||
// ── Provider ──
|
||||
|
||||
func TestProvider_Name(t *testing.T) {
|
||||
p := &Provider{}
|
||||
if got := p.Name(); got != "local" {
|
||||
t.Errorf("Provider.Name() = %q, want %q", got, "local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ResolveFileIO(t *testing.T) {
|
||||
p := &Provider{}
|
||||
fio := p.ResolveFileIO(nil)
|
||||
if fio == nil {
|
||||
t.Fatal("Provider.ResolveFileIO returned nil")
|
||||
}
|
||||
if _, ok := fio.(*LocalFileIO); !ok {
|
||||
t.Errorf("expected *LocalFileIO, got %T", fio)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Open ──
|
||||
|
||||
func TestLocalFileIO_Open_ValidFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
content := []byte("hello world")
|
||||
os.WriteFile("test.txt", content, 0644)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
f, err := fio.Open("test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Open failed: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
got, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll failed: %v", err)
|
||||
}
|
||||
if string(got) != string(content) {
|
||||
t.Errorf("content = %q, want %q", got, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Open_RejectsTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Open("../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for path traversal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Open_RejectsAbsolutePath(t *testing.T) {
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Open("/etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for absolute path")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "relative path") {
|
||||
t.Errorf("error should mention relative path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Open_NonexistentFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Open("nonexistent.txt")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stat ──
|
||||
|
||||
func TestLocalFileIO_Stat_ValidFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
os.WriteFile("stat.txt", []byte("12345"), 0644)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
info, err := fio.Stat("stat.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Stat failed: %v", err)
|
||||
}
|
||||
if info.Size() != 5 {
|
||||
t.Errorf("Size() = %d, want 5", info.Size())
|
||||
}
|
||||
if info.IsDir() {
|
||||
t.Error("expected IsDir() = false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Stat_RejectsTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Stat("../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for path traversal")
|
||||
}
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
t.Error("traversal should not be os.IsNotExist, should be a validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Stat_NonexistentReturnsIsNotExist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Stat("nope.txt")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("expected os.IsNotExist, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save ──
|
||||
|
||||
func TestLocalFileIO_Save_WritesContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
body := strings.NewReader("saved content")
|
||||
result, err := fio.Save("output.bin", fileio.SaveOptions{}, body)
|
||||
if err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
if result.Size() != int64(len("saved content")) {
|
||||
t.Errorf("Size() = %d, want %d", result.Size(), len("saved content"))
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "output.bin"))
|
||||
if string(got) != "saved content" {
|
||||
t.Errorf("file content = %q, want %q", got, "saved content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Save_CreatesParentDirs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
body := strings.NewReader("nested")
|
||||
_, err := fio.Save(filepath.Join("a", "b", "c.txt"), fileio.SaveOptions{}, body)
|
||||
if err != nil {
|
||||
t.Fatalf("Save with nested dir failed: %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "a", "b", "c.txt"))
|
||||
if string(got) != "nested" {
|
||||
t.Errorf("file content = %q, want %q", got, "nested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Save_RejectsTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Save("../../evil.txt", fileio.SaveOptions{}, strings.NewReader("bad"))
|
||||
if err == nil {
|
||||
t.Error("expected error for path traversal in Save")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Save_RejectsAbsolutePath(t *testing.T) {
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Save("/tmp/evil.txt", fileio.SaveOptions{}, strings.NewReader("bad"))
|
||||
if err == nil {
|
||||
t.Error("expected error for absolute path in Save")
|
||||
}
|
||||
}
|
||||
|
||||
// ── ResolvePath ──
|
||||
|
||||
func TestLocalFileIO_ResolvePath_ReturnsAbsolute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
resolved, err := fio.ResolvePath("file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePath failed: %v", err)
|
||||
}
|
||||
if !filepath.IsAbs(resolved) {
|
||||
t.Errorf("expected absolute path, got %q", resolved)
|
||||
}
|
||||
if filepath.Base(resolved) != "file.txt" {
|
||||
t.Errorf("expected base name file.txt, got %q", filepath.Base(resolved))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_ResolvePath_RejectsTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.ResolvePath("../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for path traversal in ResolvePath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_ResolvePath_RejectsAbsolute(t *testing.T) {
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.ResolvePath("/etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for absolute path in ResolvePath")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error message consistency ──
|
||||
|
||||
func TestLocalFileIO_ErrorMessages_ContainCorrectFlagName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
|
||||
// Open/Stat use SafeInputPath → errors should mention "--file"
|
||||
_, err := fio.Open("/absolute/path")
|
||||
if err == nil || !strings.Contains(err.Error(), "--file") {
|
||||
t.Errorf("Open absolute path error should mention --file, got: %v", err)
|
||||
}
|
||||
|
||||
_, err = fio.Stat("/absolute/path")
|
||||
if err == nil || !strings.Contains(err.Error(), "--file") {
|
||||
t.Errorf("Stat absolute path error should mention --file, got: %v", err)
|
||||
}
|
||||
|
||||
// Save/ResolvePath use SafeOutputPath → errors should mention "--output"
|
||||
_, err = fio.Save("/absolute/path", fileio.SaveOptions{}, strings.NewReader(""))
|
||||
if err == nil || !strings.Contains(err.Error(), "--output") {
|
||||
t.Errorf("Save absolute path error should mention --output, got: %v", err)
|
||||
}
|
||||
|
||||
_, err = fio.ResolvePath("/absolute/path")
|
||||
if err == nil || !strings.Contains(err.Error(), "--output") {
|
||||
t.Errorf("ResolvePath absolute path error should mention --output, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Control character / Unicode rejection ──
|
||||
|
||||
func TestLocalFileIO_RejectsControlCharsInPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
paths := []string{
|
||||
"file\x00name.txt", // null byte
|
||||
"file\x1fname.txt", // control char
|
||||
"file\u200Bname.txt", // zero-width space
|
||||
"file\u202Ename.txt", // bidi override
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
if _, err := fio.Open(p); err == nil {
|
||||
t.Errorf("Open(%q) should reject control/dangerous chars", p)
|
||||
}
|
||||
if _, err := fio.Save(p, fileio.SaveOptions{}, strings.NewReader("")); err == nil {
|
||||
t.Errorf("Save(%q) should reject control/dangerous chars", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
131
internal/vfs/localfileio/path.go
Normal file
131
internal/vfs/localfileio/path.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// SafeOutputPath validates a download/export target path for --output flags.
|
||||
func SafeOutputPath(path string) (string, error) {
|
||||
return safePath(path, "--output")
|
||||
}
|
||||
|
||||
// SafeInputPath validates an upload/read source path for --file flags.
|
||||
func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||
// Empty values and http/https URLs are returned unchanged without validation.
|
||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
|
||||
return value, nil
|
||||
}
|
||||
if _, err := SafeInputPath(value); err != nil {
|
||||
return "", fmt.Errorf("%s: %v", flagName, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// It requires an absolute path, rejects control characters, normalizes the
|
||||
// input, and resolves symlinks through the nearest existing ancestor.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
if err := charcheck.RejectControlChars(path, envName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path = filepath.Clean(path)
|
||||
if !filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("%s must be an absolute path, got %q", envName, path)
|
||||
}
|
||||
|
||||
resolved, err := resolveNearestAncestor(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// safePath is the shared implementation for SafeOutputPath and SafeInputPath.
|
||||
func safePath(raw, flagName string) (string, error) {
|
||||
if err := charcheck.RejectControlChars(raw, flagName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path := filepath.Clean(raw)
|
||||
|
||||
if filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
}
|
||||
|
||||
cwd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
resolved := filepath.Join(cwd, path)
|
||||
|
||||
if _, err := vfs.Lstat(resolved); err == nil {
|
||||
resolved, err = filepath.EvalSymlinks(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
} else {
|
||||
resolved, err = resolveNearestAncestor(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
canonicalCwd, _ := filepath.EvalSymlinks(cwd)
|
||||
if !isUnderDir(resolved, canonicalCwd) {
|
||||
return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw)
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func resolveNearestAncestor(path string) (string, error) {
|
||||
var tail []string
|
||||
cur := path
|
||||
for {
|
||||
if _, err := vfs.Lstat(cur); err == nil {
|
||||
real, err := filepath.EvalSymlinks(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := append([]string{real}, tail...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
parts := append([]string{cur}, tail...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
tail = append([]string{filepath.Base(cur)}, tail...)
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
func isUnderDir(child, parent string) bool {
|
||||
rel, err := filepath.Rel(parent, child)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
|
||||
}
|
||||
|
||||
// RejectControlChars delegates to charcheck.RejectControlChars.
|
||||
// Kept as a package-level alias for backward compatibility with callers
|
||||
// that import localfileio directly.
|
||||
var RejectControlChars = charcheck.RejectControlChars
|
||||
|
||||
// IsDangerousUnicode delegates to charcheck.IsDangerousUnicode.
|
||||
var IsDangerousUnicode = charcheck.IsDangerousUnicode
|
||||
245
internal/vfs/localfileio/path_test.go
Normal file
245
internal/vfs/localfileio/path_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
// ── GIVEN: normal relative paths → THEN: allowed ──
|
||||
{"normal file", "report.xlsx", false},
|
||||
{"subdir file", "output/report.xlsx", false},
|
||||
{"current dir explicit", "./file.txt", false},
|
||||
{"nested subdir", "a/b/c/file.txt", false},
|
||||
{"dot in name", "my.report.v2.xlsx", false},
|
||||
{"space in name", "my file.txt", false},
|
||||
{"unicode normal", "报告.xlsx", false},
|
||||
{"dot-dot resolves to cwd", "subdir/..", false},
|
||||
|
||||
// ── GIVEN: path traversal via .. → THEN: rejected ──
|
||||
{"dot-dot escape", "../../.ssh/authorized_keys", true},
|
||||
{"dot-dot mid path", "subdir/../../etc/passwd", true},
|
||||
{"triple dot-dot", "../../../etc/shadow", true},
|
||||
|
||||
// ── GIVEN: absolute paths → THEN: rejected ──
|
||||
{"absolute path unix", "/etc/passwd", true},
|
||||
{"absolute path root", "/tmp/evil", true},
|
||||
|
||||
// ── GIVEN: control characters in path → THEN: rejected ──
|
||||
{"null byte", "file\x00.txt", true},
|
||||
{"carriage return", "file\r.txt", true},
|
||||
{"bell char", "file\x07.txt", true},
|
||||
|
||||
// ── GIVEN: dangerous Unicode in path → THEN: rejected ──
|
||||
{"bidi RLO", "file\u202Ename.txt", true},
|
||||
{"zero width space", "file\u200Bname.txt", true},
|
||||
{"BOM char", "file\uFEFFname.txt", true},
|
||||
{"line separator", "file\u2028name.txt", true},
|
||||
{"bidi LRI", "file\u2066name.txt", true},
|
||||
|
||||
// ── GIVEN: looks dangerous but is actually safe → THEN: allowed ──
|
||||
{"literal percent 2e", "%2e%2e/etc/passwd", false},
|
||||
{"tilde path", "~/file.txt", false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// WHEN: SafeOutputPath validates the path
|
||||
_, err := SafeOutputPath(tt.input)
|
||||
|
||||
// THEN: error matches expectation
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("SafeOutputPath(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a clean temp directory as CWD
|
||||
dir := t.TempDir()
|
||||
dir, _ = filepath.EvalSymlinks(dir)
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(dir)
|
||||
|
||||
// WHEN: SafeOutputPath validates a relative path
|
||||
got, err := SafeOutputPath("output/file.txt")
|
||||
|
||||
// THEN: returns the canonical absolute path for subsequent I/O
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := filepath.Join(dir, "output", "file.txt")
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_RejectsSymlinkEscapingCWD(t *testing.T) {
|
||||
// GIVEN: a symlink in CWD pointing to /etc (outside CWD)
|
||||
dir := t.TempDir()
|
||||
dir, _ = filepath.EvalSymlinks(dir)
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(dir)
|
||||
os.Symlink("/etc", filepath.Join(dir, "link-to-etc"))
|
||||
|
||||
// WHEN: SafeOutputPath validates a path through the symlink
|
||||
_, err := SafeOutputPath("link-to-etc/passwd")
|
||||
|
||||
// THEN: rejected because the resolved path is outside CWD
|
||||
if err == nil {
|
||||
t.Error("expected error for symlink escaping CWD, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_AllowsSymlinkWithinCWD(t *testing.T) {
|
||||
// GIVEN: a symlink in CWD pointing to a subdirectory within CWD
|
||||
dir := t.TempDir()
|
||||
dir, _ = filepath.EvalSymlinks(dir)
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(dir)
|
||||
os.MkdirAll(filepath.Join(dir, "real"), 0755)
|
||||
os.Symlink(filepath.Join(dir, "real"), filepath.Join(dir, "link"))
|
||||
|
||||
// WHEN: SafeOutputPath validates a path through the internal symlink
|
||||
got, err := SafeOutputPath("link/file.txt")
|
||||
|
||||
// THEN: allowed, resolved to the real path within CWD
|
||||
if err != nil {
|
||||
t.Fatalf("symlink within CWD should be allowed: %v", err)
|
||||
}
|
||||
want := filepath.Join(dir, "real", "file.txt")
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_ResolvesAncestorSymlinkWhenParentMissing(t *testing.T) {
|
||||
// GIVEN: CWD contains a symlink "escape" → /etc, and the target path
|
||||
// goes through "escape/sub/file.txt" where "sub" does not exist.
|
||||
// The old code failed to resolve the symlink because the immediate
|
||||
// parent ("escape/sub") didn't exist, leaving resolved un-anchored.
|
||||
dir := t.TempDir()
|
||||
dir, _ = filepath.EvalSymlinks(dir)
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(dir)
|
||||
os.Symlink("/etc", filepath.Join(dir, "escape"))
|
||||
|
||||
// WHEN: SafeOutputPath validates a path through the symlink with missing intermediate dirs
|
||||
_, err := SafeOutputPath("escape/nonexistent/file.txt")
|
||||
|
||||
// THEN: rejected — the resolved path is under /etc, outside CWD
|
||||
if err == nil {
|
||||
t.Error("expected error for symlink escaping CWD via non-existent parent, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
|
||||
// GIVEN: a deeply nested non-existent path with no symlinks
|
||||
dir := t.TempDir()
|
||||
dir, _ = filepath.EvalSymlinks(dir)
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(dir)
|
||||
|
||||
// WHEN: SafeOutputPath validates "a/b/c/d/file.txt" (none of a/b/c/d exist)
|
||||
got, err := SafeOutputPath("a/b/c/d/file.txt")
|
||||
|
||||
// THEN: allowed, resolved to canonical path under CWD
|
||||
if err != nil {
|
||||
t.Fatalf("deep non-existent path within CWD should be allowed: %v", err)
|
||||
}
|
||||
want := filepath.Join(dir, "a", "b", "c", "d", "file.txt")
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
tmpPath := f.Name()
|
||||
f.Close()
|
||||
t.Cleanup(func() { os.Remove(tmpPath) })
|
||||
|
||||
// WHEN: SafeUploadPath validates the absolute temp path
|
||||
_, err = SafeInputPath(tmpPath)
|
||||
|
||||
// THEN: absolute paths are rejected even in temp dir
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute temp path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) {
|
||||
// GIVEN: an absolute path outside the temp directory
|
||||
// WHEN / THEN: SafeUploadPath rejects it
|
||||
_, err := SafeInputPath("/etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for absolute non-temp path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AcceptsRelativePath(t *testing.T) {
|
||||
// GIVEN: a clean temp CWD with a real file
|
||||
dir := t.TempDir()
|
||||
dir, _ = filepath.EvalSymlinks(dir)
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
os.Chdir(dir)
|
||||
|
||||
os.WriteFile(filepath.Join(dir, "upload.bin"), []byte("data"), 0600)
|
||||
|
||||
// WHEN: SafeUploadPath validates a relative path to an existing file
|
||||
got, err := SafeInputPath("upload.bin")
|
||||
|
||||
// THEN: accepted and returned as absolute canonical path
|
||||
if err != nil {
|
||||
t.Fatalf("SafeUploadPath(relative) error = %v", err)
|
||||
}
|
||||
want := filepath.Join(dir, "upload.bin")
|
||||
if got != want {
|
||||
t.Errorf("SafeUploadPath(relative) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_SafeInputPath_ErrorMessageContainsCorrectFlagName(t *testing.T) {
|
||||
// GIVEN: an absolute path
|
||||
|
||||
// WHEN: SafeInputPath rejects it
|
||||
_, err := SafeInputPath("/etc/passwd")
|
||||
|
||||
// THEN: error message mentions --file (not --output)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--file") {
|
||||
t.Errorf("error should mention --file, got: %s", err.Error())
|
||||
}
|
||||
|
||||
// WHEN: SafeOutputPath rejects it
|
||||
_, err = SafeOutputPath("/etc/passwd")
|
||||
|
||||
// THEN: error message mentions --output (not --file)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--output") {
|
||||
t.Errorf("error should mention --output, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vfs
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vfs
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.7",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execSync } = require("child_process");
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
/*
|
||||
* Issue labeler for this repository.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
|
||||
@@ -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
17
scripts/run.js
Normal file → Executable 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) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseFieldCreate = common.Shortcut{
|
||||
{Name: "json", Desc: "field property JSON object", Required: true},
|
||||
{Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Status","type":"text"}'`,
|
||||
"Agent hint: use the lark-base skill's field-create guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFieldCreate(runtime)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ var BaseFieldUpdate = common.Shortcut{
|
||||
{Name: "json", Desc: "field property JSON object", Required: true},
|
||||
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Status","type":"text"}'`,
|
||||
"Agent hint: use the lark-base skill's field-update guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFieldUpdate(runtime)
|
||||
},
|
||||
|
||||
@@ -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{} {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseRecordUpsert = common.Shortcut{
|
||||
recordRefFlag(false),
|
||||
{Name: "json", Desc: "record JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"Name":"Alice"}'`,
|
||||
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ var BaseViewCreate = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "view JSON object/array", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Main","type":"grid"}'`,
|
||||
"Agent hint: use the lark-base skill's view-create guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewCreate(runtime)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseViewSetCard = common.Shortcut{
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "card JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"cover_field":"fldCover"}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-card guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseViewSetFilter = common.Shortcut{
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "filter JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"logic":"and","conditions":[["fldStatus","==","Todo"]]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-filter guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
|
||||
@@ -20,7 +20,11 @@ var BaseViewSetGroup = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "group JSON object/array", Required: true},
|
||||
{Name: "json", Desc: "group JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"group_config":[{"field":"fldStatus","desc":false}]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONValue(runtime)
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseViewSetSort = common.Shortcut{
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "sort JSON object/array", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '[{"field":"fldPriority","desc":true}]'`,
|
||||
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONValue(runtime)
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user