mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 18:34:03 +08:00
Compare commits
5 Commits
sun/docs-u
...
fix/endpoi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da87c69627 | ||
|
|
74d8458635 | ||
|
|
80fadf1801 | ||
|
|
c04da4723a | ||
|
|
a09388d035 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,6 +2,29 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.67] - 2026-07-08
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: add message modify and trash shortcuts (#1567)
|
||||
- support whiteboard file inputs in docs XML (#1784)
|
||||
- **vc**: refine meeting-events output and reaction forwarding (#1674)
|
||||
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- accept opaque wiki node tokens (#1789)
|
||||
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
|
||||
- preserve original filename in multipart file upload (#1767)
|
||||
|
||||
### Documentation
|
||||
|
||||
- restore one-time authorization guidance in lark-apps skill (#1794)
|
||||
|
||||
### Misc
|
||||
|
||||
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
|
||||
|
||||
## [v1.0.66] - 2026-07-07
|
||||
|
||||
### Features
|
||||
@@ -1398,6 +1421,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
|
||||
@@ -916,25 +916,6 @@ func TestReadDotenv_ValueWithEquals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBrand(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"", "feishu"},
|
||||
{"feishu", "feishu"},
|
||||
{"lark", "lark"},
|
||||
{"LARK", "lark"},
|
||||
{" lark ", "lark"},
|
||||
{"Lark", "lark"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := normalizeBrand(tt.input); got != tt.want {
|
||||
t.Errorf("normalizeBrand(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenClawConfigPath_Overrides(t *testing.T) {
|
||||
t.Run("OPENCLAW_CONFIG_PATH wins", func(t *testing.T) {
|
||||
custom := filepath.Join(t.TempDir(), "custom.json")
|
||||
|
||||
@@ -205,7 +205,7 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return &core.AppConfig{
|
||||
AppId: selected.AppID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
|
||||
Brand: core.ParseBrand(selected.Brand),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])),
|
||||
Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
|
||||
Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -350,16 +350,6 @@ func sourceDisplayName(source string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeBrand applies .strip().lower() and defaults to "feishu".
|
||||
// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior.
|
||||
func normalizeBrand(raw string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
if s == "" {
|
||||
return "feishu"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// resolveHermesEnvPath returns the path to Hermes's .env file.
|
||||
// Respects HERMES_HOME override; defaults to ~/.hermes/.env.
|
||||
//
|
||||
|
||||
@@ -146,6 +146,20 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
||||
}, nil
|
||||
}
|
||||
|
||||
// retryBrand decides whether app-registration polling must be retried on a
|
||||
// different brand. When the secret was not issued and the tenant's real brand
|
||||
// differs from the brand we polled, it returns the tenant's actual brand and true.
|
||||
func retryBrand(polled core.LarkBrand, tenantBrand string) (core.LarkBrand, bool) {
|
||||
if tenantBrand == "" {
|
||||
return polled, false
|
||||
}
|
||||
actual := core.ParseBrand(tenantBrand)
|
||||
if actual == polled {
|
||||
return polled, false
|
||||
}
|
||||
return actual, true
|
||||
}
|
||||
|
||||
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
|
||||
// If brandOverride is non-empty, skip the interactive brand selection.
|
||||
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
|
||||
@@ -208,18 +222,19 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
|
||||
}
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, larkBrand, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 4: Handle Lark brand special case
|
||||
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
|
||||
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
|
||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
|
||||
// Step 4: Retry on the tenant's actual brand if the secret was not issued on
|
||||
// the polled brand (e.g. configured feishu but the tenant is a lark tenant).
|
||||
if result.ClientSecret == "" && result.UserInfo != nil {
|
||||
if rb, ok := retryBrand(larkBrand, result.UserInfo.TenantBrand); ok {
|
||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, rb, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "brand endpoint retry failed: %v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,12 +242,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
|
||||
}
|
||||
|
||||
// Determine final brand from response
|
||||
// Determine the final brand from the tenant's reported brand, normalized the
|
||||
// same way as the retry decision (core.ParseBrand) so the saved brand always
|
||||
// matches the endpoint the credentials were issued on.
|
||||
finalBrand := larkBrand
|
||||
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
finalBrand = core.BrandLark
|
||||
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
|
||||
finalBrand = core.BrandFeishu
|
||||
if result.UserInfo != nil && result.UserInfo.TenantBrand != "" {
|
||||
finalBrand = core.ParseBrand(result.UserInfo.TenantBrand)
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
|
||||
31
cmd/config/init_interactive_test.go
Normal file
31
cmd/config/init_interactive_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestRetryBrand(t *testing.T) {
|
||||
cases := []struct {
|
||||
polled core.LarkBrand
|
||||
tenant string
|
||||
wantBrand core.LarkBrand
|
||||
wantRetry bool
|
||||
}{
|
||||
{core.BrandFeishu, "lark", core.BrandLark, true},
|
||||
{core.BrandFeishu, "feishu", core.BrandFeishu, false},
|
||||
{core.BrandFeishu, "", core.BrandFeishu, false},
|
||||
{core.BrandLark, "feishu", core.BrandFeishu, true},
|
||||
{core.BrandLark, "lark", core.BrandLark, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
gotB, gotR := retryBrand(c.polled, c.tenant)
|
||||
if gotR != c.wantRetry || gotB != c.wantBrand {
|
||||
t.Errorf("retryBrand(%q,%q) = (%q,%v), want (%q,%v)", c.polled, c.tenant, gotB, gotR, c.wantBrand, c.wantRetry)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
@@ -125,6 +126,10 @@ func updateRun(opts *UpdateOptions) error {
|
||||
io := opts.Factory.IOStreams
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
updater.Brand = core.BrandFeishu
|
||||
if cfg, err := opts.Factory.Config(); err == nil && cfg != nil {
|
||||
updater.Brand = cfg.Brand
|
||||
}
|
||||
|
||||
if !opts.Check {
|
||||
updater.CleanupStaleFiles()
|
||||
|
||||
6
extension/credential/env/env.go
vendored
6
extension/credential/env/env.go
vendored
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
@@ -41,10 +42,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
}
|
||||
}
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
|
||||
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
@@ -58,10 +59,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
}
|
||||
}
|
||||
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
|
||||
acct := &credential.Account{
|
||||
AppID: appID,
|
||||
|
||||
@@ -39,6 +39,13 @@ type AppRegUserInfo struct {
|
||||
TenantBrand string // "feishu" or "lark"
|
||||
}
|
||||
|
||||
// appRegistrationEndpoint returns the accounts-domain registration endpoint for
|
||||
// the given brand. Both the begin and poll actions target the same brand so the
|
||||
// device_code issuer and poller domains always match.
|
||||
func appRegistrationEndpoint(brand core.LarkBrand) string {
|
||||
return core.ResolveEndpoints(brand).Accounts + PathAppRegistration
|
||||
}
|
||||
|
||||
// RequestAppRegistration initiates the app registration device flow.
|
||||
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
|
||||
if errOut == nil {
|
||||
@@ -46,8 +53,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
|
||||
}
|
||||
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
|
||||
endpoint := regEp.Accounts + PathAppRegistration
|
||||
endpoint := appRegistrationEndpoint(brand)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "begin")
|
||||
@@ -129,8 +135,7 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
const maxPollInterval = 60
|
||||
const maxPollAttempts = 200
|
||||
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
endpoint := ep.Accounts + PathAppRegistration
|
||||
endpoint := appRegistrationEndpoint(brand)
|
||||
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
currentInterval := interval
|
||||
attempts := 0
|
||||
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
@@ -31,3 +32,18 @@ func Test_BuildVerificationURL(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppRegistrationEndpoint(t *testing.T) {
|
||||
cases := []struct {
|
||||
brand core.LarkBrand
|
||||
want string
|
||||
}{
|
||||
{core.BrandFeishu, "https://accounts.feishu.cn" + PathAppRegistration},
|
||||
{core.BrandLark, "https://accounts.larksuite.com" + PathAppRegistration},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := appRegistrationEndpoint(c.brand); got != c.want {
|
||||
t.Errorf("brand %q: endpoint = %q, want %q", c.brand, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
package core
|
||||
|
||||
import "strings"
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
// Any other string is treated as a custom base URL.
|
||||
// Any unrecognized value is normalized to BrandFeishu (the default brand).
|
||||
type LarkBrand string
|
||||
|
||||
const (
|
||||
@@ -14,9 +16,10 @@ const (
|
||||
)
|
||||
|
||||
// ParseBrand normalizes a brand string to a LarkBrand constant.
|
||||
// Unrecognized values default to BrandFeishu.
|
||||
// Matching is case-insensitive and whitespace-tolerant; any value other than
|
||||
// "lark" (after trim + lowercase) normalizes to BrandFeishu.
|
||||
func ParseBrand(value string) LarkBrand {
|
||||
if value == "lark" {
|
||||
if strings.ToLower(strings.TrimSpace(value)) == "lark" {
|
||||
return BrandLark
|
||||
}
|
||||
return BrandFeishu
|
||||
|
||||
@@ -57,3 +57,23 @@ func TestResolveOpenBaseURL(t *testing.T) {
|
||||
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrand(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want LarkBrand
|
||||
}{
|
||||
{"", BrandFeishu},
|
||||
{"feishu", BrandFeishu},
|
||||
{"lark", BrandLark},
|
||||
{"LARK", BrandLark},
|
||||
{" lark ", BrandLark},
|
||||
{"Lark", BrandLark},
|
||||
{"xyz", BrandFeishu},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ParseBrand(c.in); got != c.want {
|
||||
t.Errorf("ParseBrand(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
|
||||
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
|
||||
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
|
||||
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
|
||||
{"1062507 drive_parent_sibling_limit", 1062507, errs.CategoryAPI, errs.SubtypeQuotaExceeded, 1, "APIError"},
|
||||
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
||||
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
||||
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
||||
|
||||
@@ -17,6 +17,7 @@ var driveCodeMeta = map[int]CodeMeta{
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
|
||||
@@ -75,13 +75,7 @@ func remoteMetaURL(version string) string {
|
||||
if testMetaURL != "" {
|
||||
return testMetaURL
|
||||
}
|
||||
var base string
|
||||
switch configuredBrand {
|
||||
case core.BrandLark:
|
||||
base = "https://open.larksuite.com/api/tools/open/api_definition"
|
||||
default:
|
||||
base = "https://open.feishu.cn/api/tools/open/api_definition"
|
||||
}
|
||||
base := core.ResolveEndpoints(configuredBrand).Open + "/api/tools/open/api_definition"
|
||||
q := "protocol=meta&client_version=" + url.QueryEscape(build.Version)
|
||||
if version != "" {
|
||||
q += "&data_version=" + url.QueryEscape(version)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -49,7 +50,10 @@ const (
|
||||
|
||||
var (
|
||||
skillsIndexFetchTimeout = 10 * time.Second
|
||||
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||
// officialSkillsIndexURL, when non-empty, overrides the brand-derived skills
|
||||
// index URL. It is empty in production (the URL is resolved from the brand
|
||||
// via core.ResolveEndpoints) and set by tests to a local server.
|
||||
officialSkillsIndexURL = ""
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
@@ -101,6 +105,10 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
// Override DetectOverride / NpmInstallOverride / SkillsCommandOverride / VerifyOverride
|
||||
// / RestoreAvailableOverride for testing.
|
||||
type Updater struct {
|
||||
// Brand selects the endpoint set for skills index/source resolution.
|
||||
// Zero value resolves to the feishu default via core.ResolveEndpoints.
|
||||
Brand core.LarkBrand
|
||||
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
PnpmInstallOverride func(version string) *NpmResult
|
||||
@@ -129,6 +137,21 @@ type Updater struct {
|
||||
// New creates an Updater with default (real) behavior.
|
||||
func New() *Updater { return &Updater{} }
|
||||
|
||||
// skillsIndexURL returns the well-known skills index URL for the Updater's
|
||||
// brand. A non-empty officialSkillsIndexURL (tests only) takes precedence.
|
||||
func (u *Updater) skillsIndexURL() string {
|
||||
if officialSkillsIndexURL != "" {
|
||||
return officialSkillsIndexURL
|
||||
}
|
||||
return core.ResolveEndpoints(u.Brand).Open + "/.well-known/skills/index.json"
|
||||
}
|
||||
|
||||
// skillsSource returns the skills repository source host for the Updater's
|
||||
// brand, used as the `npx skills add <source>` argument.
|
||||
func (u *Updater) skillsSource() string {
|
||||
return core.ResolveEndpoints(u.Brand).Open
|
||||
}
|
||||
|
||||
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||
// owning package manager is available for auto-update.
|
||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
@@ -258,7 +281,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.skillsIndexURL(), nil)
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
@@ -297,7 +320,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkills() *NpmResult {
|
||||
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
r := u.runSkillsListOfficial(u.skillsSource())
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsListOfficial("larksuite/cli")
|
||||
}
|
||||
@@ -313,7 +336,7 @@ func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
|
||||
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
|
||||
r := u.runSkillsInstall(u.skillsSource(), nameList)
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsInstall("larksuite/cli", nameList)
|
||||
}
|
||||
@@ -321,7 +344,7 @@ func (u *Updater) InstallSkill(nameList []string) *NpmResult {
|
||||
}
|
||||
|
||||
func (u *Updater) InstallAllSkills() *NpmResult {
|
||||
r := u.runSkillsAdd("https://open.feishu.cn")
|
||||
r := u.runSkillsAdd(u.skillsSource())
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsAdd("larksuite/cli")
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -515,3 +516,23 @@ func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsBrandHosts(t *testing.T) {
|
||||
cases := []struct {
|
||||
brand core.LarkBrand
|
||||
wantIndex string
|
||||
wantSource string
|
||||
}{
|
||||
{core.BrandFeishu, "https://open.feishu.cn/.well-known/skills/index.json", "https://open.feishu.cn"},
|
||||
{core.BrandLark, "https://open.larksuite.com/.well-known/skills/index.json", "https://open.larksuite.com"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
u := &Updater{Brand: c.brand}
|
||||
if got := u.skillsIndexURL(); got != c.wantIndex {
|
||||
t.Errorf("brand %q: skillsIndexURL = %q, want %q", c.brand, got, c.wantIndex)
|
||||
}
|
||||
if got := u.skillsSource(); got != c.wantSource {
|
||||
t.Errorf("brand %q: skillsSource = %q, want %q", c.brand, got, c.wantSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -23,7 +24,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||
cacheTTL = 24 * time.Hour
|
||||
fetchTimeout = 15 * time.Second
|
||||
stateFile = "update-state.json"
|
||||
@@ -202,8 +202,25 @@ type npmLatestResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// resolveRegistryURL builds the npm registry metadata URL for @larksuite/cli.
|
||||
// It honors npm_config_registry when set to a valid https registry (npm
|
||||
// ecosystem convention), otherwise falls back to the public npmjs default.
|
||||
// This request carries no Lark credentials — it is package-distribution only.
|
||||
func resolveRegistryURL() string {
|
||||
const pkgPath = "/@larksuite/cli/latest"
|
||||
const defaultBase = "https://registry.npmjs.org"
|
||||
raw := strings.TrimSpace(os.Getenv("npm_config_registry"))
|
||||
if raw != "" {
|
||||
if u, err := url.Parse(raw); err == nil && u.Scheme == "https" && u.Host != "" {
|
||||
base := strings.TrimRight(u.Scheme+"://"+u.Host+u.Path, "/")
|
||||
return base + pkgPath
|
||||
}
|
||||
}
|
||||
return defaultBase + pkgPath
|
||||
}
|
||||
|
||||
func fetchLatestVersion() (string, error) {
|
||||
resp, err := httpClient().Get(registryURL)
|
||||
resp, err := httpClient().Get(resolveRegistryURL())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -275,3 +275,23 @@ func TestIsCIEnv(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRegistryURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, env, want string
|
||||
}{
|
||||
{"unset", "", "https://registry.npmjs.org/@larksuite/cli/latest"},
|
||||
{"custom https", "https://corp.example.com/repository/npm/", "https://corp.example.com/repository/npm/@larksuite/cli/latest"},
|
||||
{"trailing slashes", "https://corp.example.com///", "https://corp.example.com/@larksuite/cli/latest"},
|
||||
{"non-https ignored", "http://internal.example.com/", "https://registry.npmjs.org/@larksuite/cli/latest"},
|
||||
{"garbage ignored", "not a url", "https://registry.npmjs.org/@larksuite/cli/latest"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Setenv("npm_config_registry", c.env)
|
||||
if got := resolveRegistryURL(); got != c.want {
|
||||
t.Errorf("resolveRegistryURL() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,21 @@ lint/
|
||||
├── rule_subtype_classifier.go
|
||||
├── rule_typed_error_completeness.go
|
||||
└── *_test.go
|
||||
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
|
||||
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
|
||||
└── scan_test.go
|
||||
```
|
||||
|
||||
## Endpoint domain contract (`domaincontract`)
|
||||
|
||||
Every outbound Lark domain must be resolved through `core.ResolveEndpoints`.
|
||||
The `domaincontract` domain rejects string literals containing a resolver-owned
|
||||
host FQDN (`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`) in any
|
||||
production `.go` file. The only allowlisted files are the resolver definition
|
||||
(`internal/core/types.go`) and this rule's own host list
|
||||
(`lint/domaincontract/scan.go`). Comments and `_test.go` files are not scanned.
|
||||
To add or change an outbound endpoint, edit the resolver — never hardcode a host.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
|
||||
97
lint/domaincontract/scan.go
Normal file
97
lint/domaincontract/scan.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package domaincontract guards against hardcoded resolver-owned host FQDNs.
|
||||
// Every outbound Lark domain must be resolved via core.ResolveEndpoints; the
|
||||
// only place these host strings may appear as literals is the resolver itself.
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
|
||||
// forbiddenHosts are the resolver-owned FQDNs. They may only appear as string
|
||||
// literals in the allowlisted resolver source.
|
||||
var forbiddenHosts = []string{
|
||||
"open.feishu.cn", "accounts.feishu.cn", "mcp.feishu.cn", "applink.feishu.cn",
|
||||
"open.larksuite.com", "accounts.larksuite.com", "mcp.larksuite.com", "applink.larksuite.com",
|
||||
}
|
||||
|
||||
// allowlist maps repo-relative paths permitted to contain the literals: the
|
||||
// resolver definition itself, and this rule's own forbidden-host list.
|
||||
var allowlist = map[string]bool{
|
||||
filepath.FromSlash("internal/core/types.go"): true,
|
||||
filepath.FromSlash("lint/domaincontract/scan.go"): true,
|
||||
}
|
||||
|
||||
func skipDir(name string) bool {
|
||||
switch name {
|
||||
case "vendor", "testdata", "node_modules", ".git", ".claude":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ScanRepo walks production .go files under root and flags string literals
|
||||
// containing a forbidden resolver host outside the allowlist. Comments and
|
||||
// _test.go files are not scanned.
|
||||
func ScanRepo(root string) ([]lintapi.Violation, error) {
|
||||
var out []lintapi.Violation
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if skipDir(d.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(root, path)
|
||||
if relErr == nil && allowlist[rel] {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, perr := parser.ParseFile(fset, path, nil, 0)
|
||||
if perr != nil {
|
||||
return nil // unparseable file: not our concern
|
||||
}
|
||||
display := path
|
||||
if relErr == nil {
|
||||
display = rel
|
||||
}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
for _, host := range forbiddenHosts {
|
||||
if strings.Contains(lit.Value, host) {
|
||||
pos := fset.Position(lit.Pos())
|
||||
out = append(out, lintapi.Violation{
|
||||
Rule: "no-hardcoded-endpoint",
|
||||
Action: lintapi.ActionReject,
|
||||
File: display,
|
||||
Line: pos.Line,
|
||||
Message: "hardcoded resolver host " + host + " — outbound domains must come from core.ResolveEndpoints",
|
||||
Suggestion: "use core.ResolveEndpoints(brand) instead of a literal host",
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
44
lint/domaincontract/scan_test.go
Normal file
44
lint/domaincontract/scan_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, root, rel, content string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanRepo(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Negative: allowlisted resolver source may hold the literals.
|
||||
writeFile(t, root, "internal/core/types.go", "package core\n\nvar x = \"https://open.feishu.cn\"\n")
|
||||
// Negative: non-resolver hosts + a comment reference must not trip the guard.
|
||||
writeFile(t, root, "shortcuts/x/display.go", "package x\n\n// see https://open.feishu.cn/document/foo\nvar h = \"https://www.feishu.cn\"\nvar e = \"https://example.feishu.cn\"\nvar r = \"https://registry.npmjs.org/pkg\"\n")
|
||||
// Negative: _test.go files may assert literals.
|
||||
writeFile(t, root, "internal/y/y_test.go", "package y\n\nvar w = \"https://open.larksuite.com\"\n")
|
||||
// Positive: production literal outside the allowlist.
|
||||
writeFile(t, root, "internal/z/z.go", "package z\n\nvar bad = \"https://accounts.larksuite.com/oauth\"\n")
|
||||
|
||||
vs, err := ScanRepo(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vs) != 1 {
|
||||
t.Fatalf("got %d violations, want 1: %+v", len(vs), vs)
|
||||
}
|
||||
if filepath.Base(vs[0].File) != "z.go" {
|
||||
t.Errorf("violation in %q, want z.go", vs[0].File)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/lint/domaincontract"
|
||||
"github.com/larksuite/cli/lint/errscontract"
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
@@ -43,6 +44,9 @@ type scanner struct {
|
||||
|
||||
var scanners = []scanner{
|
||||
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
|
||||
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||
return domaincontract.ScanRepo(root)
|
||||
}},
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.66",
|
||||
"version": "1.0.67",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
73
shortcuts/base/record_json_shorthand_test.go
Normal file
73
shortcuts/base/record_json_shorthand_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func mountBaseShortcutFlags(t *testing.T, s common.Shortcut, name string) *cobra.Command {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, &cmdutil.Factory{})
|
||||
cmd, _, err := parent.Find([]string{name})
|
||||
if err != nil {
|
||||
t.Fatalf("Find(%s) error = %v", name, err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// record-list 获得 --json 简写
|
||||
func TestRecordListRegistersJSONShorthand(t *testing.T) {
|
||||
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("+record-list missing --json shorthand")
|
||||
}
|
||||
if fl.Usage != "shorthand for --format json" {
|
||||
t.Errorf("usage = %q, want shorthand", fl.Usage)
|
||||
}
|
||||
if def := cmd.Flags().Lookup("format").DefValue; def != "markdown" {
|
||||
t.Errorf("format default = %q, want markdown (unchanged)", def)
|
||||
}
|
||||
}
|
||||
|
||||
// record-search / record-get 的 --json 保持请求体语义,不被覆盖(回归锚点)
|
||||
func TestRecordSearchGetKeepRequestBodyJSON(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
cmdName string
|
||||
}{
|
||||
{"record-search", BaseRecordSearch, "+record-search"},
|
||||
{"record-get", BaseRecordGet, "+record-get"},
|
||||
} {
|
||||
cmd := mountBaseShortcutFlags(t, tc.shortcut, tc.cmdName)
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatalf("%s: --json (request body) missing", tc.name)
|
||||
}
|
||||
if strings.Contains(fl.Usage, "shorthand") {
|
||||
t.Fatalf("%s: request-body --json overwritten by shorthand: %q", tc.name, fl.Usage)
|
||||
}
|
||||
if fl.Value.Type() != "string" {
|
||||
t.Fatalf("%s: --json type = %q, want string", tc.name, fl.Value.Type())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enum 已接入:help 描述携带枚举后缀(框架对带 Enum 的 flag 自动追加 " (markdown|json)")
|
||||
func TestRecordReadFormatFlagCarriesEnum(t *testing.T) {
|
||||
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
|
||||
usage := cmd.Flags().Lookup("format").Usage
|
||||
if !strings.Contains(usage, "(markdown|json)") {
|
||||
t.Fatalf("format usage missing enum suffix: %q", usage)
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ func recordReadFormatFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "format",
|
||||
Default: "markdown",
|
||||
Enum: []string{"markdown", "json"},
|
||||
Desc: "output format: markdown (default) | json",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1027,6 +1027,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
}
|
||||
rctx.larkSDK = sdk
|
||||
|
||||
applyJSONShorthand(cmd, s)
|
||||
rctx.Format = rctx.Str("format")
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx, nil
|
||||
@@ -1172,6 +1173,75 @@ func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut)
|
||||
registerShortcutFlagsWithContext(context.Background(), cmd, f, s)
|
||||
}
|
||||
|
||||
// shortcutDeclaresJSONFlag reports whether the shortcut itself declares a flag
|
||||
// named "json" in its Flags list (custom semantics, e.g. event +subscribe's
|
||||
// pretty-print switch or base +record-search's request-body payload).
|
||||
// Framework-injected flags never appear in s.Flags, so this cleanly separates
|
||||
// "self-declared json" from "injected shorthand".
|
||||
func shortcutDeclaresJSONFlag(s *Shortcut) bool {
|
||||
for _, fl := range s.Flags {
|
||||
if fl.Name == "json" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
|
||||
// "json": a self-declared format supports it only when its Enum lists "json";
|
||||
// a framework-injected default format (no format entry in s.Flags) always does.
|
||||
func shortcutFormatSupportsJSON(s *Shortcut) bool {
|
||||
for _, fl := range s.Flags {
|
||||
if fl.Name == "format" {
|
||||
return slices.Contains(fl.Enum, "json")
|
||||
}
|
||||
}
|
||||
return true // framework-injected: json (default) | pretty | table | ndjson | csv
|
||||
}
|
||||
|
||||
// ensureJSONShorthand registers --json as a shorthand for --format json when:
|
||||
// 1. the command has a format flag (self-declared or framework-injected), AND
|
||||
// 2. that format supports "json" (see shortcutFormatSupportsJSON), AND
|
||||
// 3. no flag named "json" is registered yet — pflag panics on duplicate
|
||||
// registration, and commands that declare their own --json (event
|
||||
// +subscribe, base +record-search/-get) keep their custom semantics.
|
||||
func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
||||
// A shortcut that declares its own "json" flag defines custom semantics
|
||||
// (e.g. pretty-print switch, request-body payload) — never a shorthand.
|
||||
if shortcutDeclaresJSONFlag(s) {
|
||||
return
|
||||
}
|
||||
if cmd.Flags().Lookup("format") == nil {
|
||||
return
|
||||
}
|
||||
if !shortcutFormatSupportsJSON(s) {
|
||||
return
|
||||
}
|
||||
// Safety net: pflag panics on duplicate registration.
|
||||
if cmd.Flags().Lookup("json") != nil {
|
||||
return
|
||||
}
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
}
|
||||
|
||||
// applyJSONShorthand folds the injected --json shorthand into the format flag
|
||||
// itself, before rctx.Format caches it — so both the cached value (OutFormat,
|
||||
// ValidateJqFlags, dry-run) and later runtime.Str("format") reads observe
|
||||
// "json". An explicitly passed --format always wins over the shorthand (the
|
||||
// shorthand only fills in when the user did not choose a format). Shortcuts
|
||||
// that declare their own "json" flag keep its custom semantics untouched.
|
||||
func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
||||
if shortcutDeclaresJSONFlag(s) {
|
||||
return
|
||||
}
|
||||
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
|
||||
return
|
||||
}
|
||||
if set, _ := cmd.Flags().GetBool("json"); set {
|
||||
_ = cmd.Flags().Set("format", "json")
|
||||
}
|
||||
}
|
||||
|
||||
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
|
||||
for _, fl := range s.Flags {
|
||||
desc := fl.Desc
|
||||
@@ -1235,10 +1305,8 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
}
|
||||
}
|
||||
ensureJSONShorthand(cmd, s)
|
||||
if s.Risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
|
||||
200
shortcuts/common/runner_json_shorthand_test.go
Normal file
200
shortcuts/common/runner_json_shorthand_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
const jsonShorthandUsage = "shorthand for --format json"
|
||||
|
||||
func mountTestShortcut(t *testing.T, s Shortcut) *cobra.Command {
|
||||
t.Helper()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
s.Mount(parent, f)
|
||||
cmd, _, err := parent.Find([]string{s.Command})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// 自定义 format 且 Enum 含 json → 注册简写(本次修复的核心行为)
|
||||
func TestJSONShorthand_CustomFormatWithJSONEnum_Registered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "mail", Command: "+fake-triage", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("--json not registered for custom-format shortcut whose Enum contains json")
|
||||
}
|
||||
if fl.Usage != jsonShorthandUsage {
|
||||
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
|
||||
}
|
||||
// 默认输出格式不被改变
|
||||
if def := cmd.Flags().Lookup("format").DefValue; def != "table" {
|
||||
t.Errorf("format default = %q, want table", def)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义 format 但 Enum 不含 json → 不注册
|
||||
func TestJSONShorthand_CustomFormatWithoutJSONEnum_NotRegistered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "x", Command: "+no-json", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "csv", Enum: []string{"csv", "table"}, Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
if cmd.Flags().Lookup("json") != nil {
|
||||
t.Fatal("--json must NOT be registered when format Enum lacks json")
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义 format 但无 Enum(现状 triage 形态)→ 不注册(Enum 是判定依据)
|
||||
func TestJSONShorthand_CustomFormatNoEnum_NotRegistered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "x", Command: "+legacy", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "table", Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
if cmd.Flags().Lookup("json") != nil {
|
||||
t.Fatal("--json must NOT be registered when format has no Enum metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// 自声明 json flag(subscribe 的 pretty / record-search 的请求体)→ 不覆盖、不 panic、语义保留
|
||||
func TestJSONShorthand_SelfDeclaredJSON_Preserved(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "event", Command: "+fake-subscribe", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("self-declared --json missing")
|
||||
}
|
||||
if fl.Usage != "pretty-print JSON instead of NDJSON" {
|
||||
t.Errorf("self-declared --json usage overwritten: %q", fl.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// parseMounted mounts the shortcut and parses args against the command's FlagSet
|
||||
// (registration side effects included), without executing RunE.
|
||||
func parseMounted(t *testing.T, s Shortcut, args []string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := mountTestShortcut(t, s)
|
||||
if err := cmd.ParseFlags(args); err != nil {
|
||||
t.Fatalf("ParseFlags(%v) error = %v", args, err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func customFormatShortcut() Shortcut {
|
||||
return Shortcut{
|
||||
Service: "mail", Command: "+fake-triage", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
}
|
||||
|
||||
// --json 单独使用 → format 归一化为 json
|
||||
func TestApplyJSONShorthand_JSONAlone_SetsFormatJSON(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, []string{"--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
|
||||
t.Fatalf("format = %q, want json", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 显式 --format 优先于 --json 简写:--format table --json → table
|
||||
func TestApplyJSONShorthand_ExplicitFormatWins(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, []string{"--format", "table", "--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
|
||||
t.Fatalf("format = %q, want table (explicit --format must win)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --format json --json → json(一致,无冲突)
|
||||
func TestApplyJSONShorthand_ExplicitJSONFormatConsistent(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, []string{"--format", "json", "--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
|
||||
t.Fatalf("format = %q, want json", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 均不传 → 默认值不变
|
||||
func TestApplyJSONShorthand_NoFlags_DefaultUntouched(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, nil)
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
|
||||
t.Fatalf("format = %q, want table (default untouched)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 自声明 string 型 --json(record-search 形态:format+json 双声明)→ 归一化跳过
|
||||
func TestApplyJSONShorthand_SelfDeclaredStringJSON_Skipped(t *testing.T) {
|
||||
s := Shortcut{
|
||||
Service: "base", Command: "+fake-record-search", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "format", Default: "markdown", Enum: []string{"markdown", "json"}, Desc: "fmt"},
|
||||
{Name: "json", Desc: "request body JSON object"},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
cmd := parseMounted(t, s, []string{"--json", `{"keyword":"Alice"}`})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "markdown" {
|
||||
t.Fatalf("format = %q, want markdown (self-declared json must not normalize)", got)
|
||||
}
|
||||
if got := cmd.Flags().Lookup("json").Value.String(); got != `{"keyword":"Alice"}` {
|
||||
t.Fatalf("request-body --json corrupted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 自声明 bool 型 --json(subscribe 形态:无自定义 format,框架注入 format)→ 归一化跳过
|
||||
func TestApplyJSONShorthand_SelfDeclaredBoolJSON_Skipped(t *testing.T) {
|
||||
s := Shortcut{
|
||||
Service: "event", Command: "+fake-subscribe", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
cmd := parseMounted(t, s, []string{"--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
// 注入的 format 默认即 json;这里断言的是 Changed 状态未被归一化污染
|
||||
if cmd.Flags().Changed("format") {
|
||||
t.Fatal("normalization must not touch format for shortcuts declaring their own --json")
|
||||
}
|
||||
}
|
||||
|
||||
// 无自定义 format(普通命令)→ 注入默认 format + 简写(现状回归)
|
||||
func TestJSONShorthand_DefaultInjectedFormat_StillRegistered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "im", Command: "+plain", Description: "x",
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("--json missing on default-format shortcut (regression)")
|
||||
}
|
||||
if fl.Usage != jsonShorthandUsage {
|
||||
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
|
||||
}
|
||||
}
|
||||
@@ -623,6 +623,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
|
||||
decision.Class = "rate_limited"
|
||||
decision.Terminal = true
|
||||
case problem.Code == 1062507:
|
||||
decision.Class = "parent_sibling_limit"
|
||||
decision.Terminal = true
|
||||
decision.Hint = "The destination parent folder has reached its child-count limit. Clean up that folder, choose another --folder-token, or split the upload across subfolders before retrying."
|
||||
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
|
||||
decision.Class = "file_size_limit"
|
||||
case problem.Code == 1062009:
|
||||
|
||||
@@ -1334,6 +1334,75 @@ func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterCreateFolderParentSiblingLimit(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll a: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll b: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1062507,
|
||||
"msg": "parent node out of sibling num.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "parent_sibling_limit" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(1062507) || item["subtype"] != "quota_exceeded" || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "child-count limit") {
|
||||
t.Fatalf("hint should explain the destination folder child-count limit, got item=%#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["rel_path"] == "b" {
|
||||
t.Fatalf("parent sibling limit must abort before b, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
|
||||
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
||||
@@ -247,10 +246,7 @@ var EventSubscribe = common.Shortcut{
|
||||
}
|
||||
|
||||
// --- WebSocket ---
|
||||
domain := lark.FeishuBaseUrl
|
||||
if runtime.Config.Brand == core.BrandLark {
|
||||
domain = lark.LarkBaseUrl
|
||||
}
|
||||
domain := core.ResolveEndpoints(runtime.Config.Brand).Open
|
||||
|
||||
info(fmt.Sprintf("%sConnecting to Lark event WebSocket...%s", output.Cyan, output.Reset))
|
||||
if eventTypeFilter != nil {
|
||||
|
||||
24
shortcuts/event/subscribe_test.go
Normal file
24
shortcuts/event/subscribe_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
)
|
||||
|
||||
// TestWSDomainMatchesResolver guards the invariant that lets the event
|
||||
// WebSocket domain be resolved via core.ResolveEndpoints instead of the SDK
|
||||
// base-URL constants: the resolver's Open host must equal the SDK's per-brand
|
||||
// base URL. If the SDK constants ever drift from the resolver, this fails.
|
||||
func TestWSDomainMatchesResolver(t *testing.T) {
|
||||
if got, want := core.ResolveEndpoints(core.BrandFeishu).Open, lark.FeishuBaseUrl; got != want {
|
||||
t.Errorf("feishu WS domain = %q, want SDK %q", got, want)
|
||||
}
|
||||
if got, want := core.ResolveEndpoints(core.BrandLark).Open, lark.LarkBaseUrl; got != want {
|
||||
t.Errorf("lark WS domain = %q, want SDK %q", got, want)
|
||||
}
|
||||
}
|
||||
112
shortcuts/mail/mail_json_shorthand_test.go
Normal file
112
shortcuts/mail/mail_json_shorthand_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// help 必须列出 --json 简写
|
||||
func TestMailTriageHelpListsJSONShorthand(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcutWithCobraOutput(t, MailTriage, []string{"+triage", "-h"}, f, stdout); err != nil {
|
||||
t.Fatalf("help returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "shorthand for --format json") {
|
||||
t.Fatalf("triage help missing --json shorthand\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailWatchHelpListsJSONShorthand(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcutWithCobraOutput(t, MailWatch, []string{"+watch", "-h"}, f, stdout); err != nil {
|
||||
t.Fatalf("help returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "shorthand for --format json") {
|
||||
t.Fatalf("watch help missing --json shorthand\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 行为验证:--json 走 JSON 输出路径,不输出 table read hint
|
||||
func TestMailTriageJSONShorthandDoesNotEmitReadHint(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage --json returned error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
if strings.Contains(stderr.String(), "tip: read full content:") {
|
||||
t.Fatalf("--json must follow the JSON path, got table hint\nstderr=%s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"messages"`) {
|
||||
t.Fatalf("--json stdout missing JSON payload\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 等价性验证:--json 与 --format json 的 dry-run 输出一致
|
||||
func TestMailTriageJSONShorthandDryRunEquivalence(t *testing.T) {
|
||||
f1, stdout1, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1", "--dry-run"}, f1, stdout1); err != nil {
|
||||
t.Fatalf("--json --dry-run error: %v", err)
|
||||
}
|
||||
f2, stdout2, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "json", "--max", "1", "--dry-run"}, f2, stdout2); err != nil {
|
||||
t.Fatalf("--format json --dry-run error: %v", err)
|
||||
}
|
||||
if stdout1.String() != stdout2.String() {
|
||||
t.Fatalf("dry-run outputs differ:\n--json:\n%s\n--format json:\n%s", stdout1.String(), stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级验证:显式 --format table 优先,--json 让位 → 仍走 table 路径
|
||||
func TestMailTriageExplicitTableWinsOverJSONShorthand(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "table", "--json", "--max", "1"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "tip: read full content:") {
|
||||
t.Fatalf("explicit --format table must win over --json (expected table hint)\nstderr=%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 错误验证:Enum 硬校验
|
||||
func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "bogus", "--max", "1", "--dry-run"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for --format bogus")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T, want typed errs problem carrier", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != "--format" {
|
||||
t.Fatalf("param = %q, want --format", ve.Param)
|
||||
}
|
||||
if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) {
|
||||
t.Fatalf("message = %q, want enum validation message", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "table, json, data") {
|
||||
t.Fatalf("message = %q, want allowed values list", problem.Message)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ var MailTriage = common.Shortcut{
|
||||
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "table", Desc: "output format: table | json | data (json/data output object with pagination fields)"},
|
||||
{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "output format: table | json | data (json/data output object with pagination fields)"},
|
||||
{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
|
||||
{Name: "page-size", Type: "int", Desc: "alias for --max"},
|
||||
{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},
|
||||
|
||||
@@ -99,7 +99,7 @@ var MailWatch = common.Shortcut{
|
||||
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
{Name: "format", Default: "data", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
{Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"},
|
||||
{Name: "output-dir", Desc: "Write each message as a JSON file (always full payload, regardless of --msg-format)"},
|
||||
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
name: lark-doc
|
||||
version: 2.0.0
|
||||
description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
|
||||
metadata:
|
||||
requires:
|
||||
|
||||
@@ -122,8 +122,6 @@ lark-cli docs +update --doc "<doc_id>" --command block_replace \
|
||||
--content '<p>替换后的段落内容</p>'
|
||||
```
|
||||
|
||||
投票 block 也使用 `block_replace` 做整块替换:replacement 中的新 `<poll>` 会创建新的投票 block,不继承旧投票的 block id、option id、票数、投票人、发布时间或当前用户选择;如需把投票改成普通内容,直接把 `--content` 写成目标 XML。
|
||||
|
||||
### block_delete — 删除指定 block
|
||||
|
||||
```bash
|
||||
|
||||
@@ -9,7 +9,6 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
|-|-|-|
|
||||
| `<title>` | 文档标题(每篇唯一)| `align` |
|
||||
| `<checkbox>` | 待办项| `done="true"\|"false"` |
|
||||
| `<poll>` | 投票块,支持创建草稿、可选创建后发布 | `poll-type`, `is-anonymous`, `enable-due-time`, `due-time`, `publish-on-create` |
|
||||
|
||||
## 容器标签
|
||||
|标签|说明|关键属性|
|
||||
@@ -49,40 +48,6 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
- `<sub-page-list>` — `<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
|
||||
- bitable、base_ref、synced_reference、synced_source、okr — 不可创建,仅支持移动
|
||||
|
||||
## 投票 block
|
||||
|
||||
投票使用结构化 XML 表达。默认创建未发布草稿:空标题、两个空选项、单选、实名、无截止时间、结果策略固定为投票后可见。
|
||||
|
||||
```xml
|
||||
<poll></poll>
|
||||
```
|
||||
|
||||
带内容创建:
|
||||
|
||||
```xml
|
||||
<poll poll-type="single" is-anonymous="false">
|
||||
<poll-title>午饭吃什么?</poll-title>
|
||||
<poll-option>米饭</poll-option>
|
||||
<poll-option>面条</poll-option>
|
||||
</poll>
|
||||
```
|
||||
|
||||
创建后尝试发布:
|
||||
|
||||
```xml
|
||||
<poll publish-on-create="true">
|
||||
<poll-title>午饭吃什么?</poll-title>
|
||||
<poll-option>米饭</poll-option>
|
||||
<poll-option>面条</poll-option>
|
||||
</poll>
|
||||
```
|
||||
|
||||
公开可写属性只有:`poll-type="single|multiple"`、`is-anonymous="true|false"`、`enable-due-time="true|false"`、`due-time="毫秒时间戳"`、`publish-on-create="true|false"`。不要写入 `when-result-visible`、`option-id`、票数、投票人或当前用户投票状态。
|
||||
|
||||
读取已发布投票时,XML 可能带只读结果字段,例如 `is-published`、`result-visible`、`user-count`、`poll-option count/percent/selected/voters-ref`。这些字段只用于展示,重新导入或 `block_replace` 时会被忽略;匿名投票不会通过 `reference_map` 暴露真实投票人。`voters-ref` 是读取详情的 opaque handle,不是投票操作入口。
|
||||
|
||||
修改投票配置、替换已发布投票、把投票替换成普通内容,都使用 `block_replace`,语义是删除旧 block 并插入 replacement。新 `<poll>` 会创建新的投票 block,不继承旧 block id、option id、票数、投票人、发布时间或当前用户选择。
|
||||
|
||||
# 四、块级复制与移动
|
||||
|
||||
## 移动(block_move_after)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-drive
|
||||
version: 1.0.0
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -21,6 +21,8 @@ metadata:
|
||||
## 快速决策
|
||||
|
||||
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token,先用 `lark-cli drive +inspect` 获取底层 `token` 和 `type`,不要把 wiki token 直接当 `file_token`。`params.file_token` 传源文档 token,`data.folder_token` 传目标文件夹 token,`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
|
||||
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
|
||||
- 高风险写操作(删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
@@ -162,7 +164,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
>
|
||||
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`,必须按 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md) 的模板通过 `--params` 传 `folder_token` / `page_token`,并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
|
||||
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`,使用前先读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md),按模板通过 `--params` 传参并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
|
||||
|
||||
### files
|
||||
|
||||
@@ -204,10 +206,12 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
### file.statistics
|
||||
|
||||
- `get` — 获取文件统计信息
|
||||
- 获取 docx / 文件统计信息时,建议优先使用 typed flags:`lark-cli drive file.statistics get --file-token <token> --file-type <type> --format json`;`--params` JSON 也支持,适合批量拼装或 raw 参数场景。
|
||||
|
||||
### file.view_records
|
||||
|
||||
- `list` — 获取文档的访问者记录
|
||||
- 查看 docx 最近访问记录、返回 open_id、最多 N 条时,建议优先使用 typed flags:`lark-cli drive file.view_records list --file-token <docx_token> --file-type docx --page-size <N> --viewer-id-type open_id --format json`;`--params` JSON 也支持,适合批量拼装、分页续跑或 raw 参数场景。
|
||||
|
||||
### file.comment.reply.reactions
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**高风险写操作**。CLI 层要求显式传 `--yes`;如果用户已经明确要求删除且目标明确,直接执行并带上 `--yes`。
|
||||
> “目标明确”表示用户给出了可解析为 `file-token` + `type` 的具体 URL/token,或对你刚列出的可解析资源列表逐项/整批确认删除。按“没用的”“临时的”“疑似重复的”“全部旧文件”等描述搜索出来的候选属于待确认目标;这类请求先列候选、说明筛选依据和影响范围,然后停止等待确认。
|
||||
|
||||
## 删除前门槛
|
||||
|
||||
执行 `drive +delete --yes` 前同时满足:
|
||||
|
||||
| 条件 | 可执行信号 |
|
||||
|------|------------|
|
||||
| 具体目标 | 单个可解析为 `file-token` + `type` 的 URL/token,或用户确认过且可解析的资源列表 |
|
||||
| 执行确认 | 用户在本轮明确说确认删除这些具体目标 |
|
||||
|
||||
若缺少任一条件,使用 `drive +search`、`drive +inspect` 或只读 API 收集候选并回复待确认清单;启发式规则(打开时间、标题模式、owner、文件类型等)只能作为候选筛选依据,不能升级为删除确认。执行 `drive +delete` 时必须使用解析后的 `--file-token` 和 `--type`。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -41,12 +41,37 @@ lark-cli drive files list \
|
||||
|
||||
也可以省略 `folder_token` 字段来请求根目录,但在 Agent 编排中建议显式传空字符串,避免把“忘记传参数”和“确认请求根目录”混在一起。
|
||||
|
||||
## 按时间排序
|
||||
|
||||
默认不要传 `order_by` / `direction`;服务端会按默认顺序返回。只有用户明确要求按创建时间或编辑时间排序时,才使用服务端排序参数。
|
||||
|
||||
按创建时间升序列出当前文件夹直接子项:
|
||||
|
||||
```bash
|
||||
lark-cli drive files list \
|
||||
--params '{"folder_token":"<folder_token>","order_by":"CreatedTime","direction":"ASC","page_size":200}' \
|
||||
--format json
|
||||
```
|
||||
|
||||
按编辑时间降序列出当前文件夹直接子项:
|
||||
|
||||
```bash
|
||||
lark-cli drive files list \
|
||||
--params '{"folder_token":"<folder_token>","order_by":"EditedTime","direction":"DESC","page_size":200}' \
|
||||
--format json
|
||||
```
|
||||
|
||||
以上示例返回排序后的当前页;如果返回 `has_more=true`,保持相同 `folder_token` / `order_by` / `direction` / `page_size`,把 `next_page_token` 放入 `page_token` 继续翻页。
|
||||
|
||||
## 参数规则
|
||||
|
||||
1. `folder_token` 必须放在 `--params` JSON 里;不要使用不存在的 `--folder-token` flag。
|
||||
2. `page_token` 必须放在 `--params` JSON 里;不要依赖 shell 变量拼接不完整的 JSON。
|
||||
3. `page_size` 建议显式设置为 `200`。如果服务端或环境返回参数错误,再降级到服务端允许的值,并记录降级原因。
|
||||
4. 调用前如果不确定字段结构,先运行 `lark-cli schema drive.files.list` 查看 `--params` 结构。
|
||||
3. 默认不要传 `order_by` / `direction`;只有用户明确要求按创建时间 / 编辑时间排序时才使用服务端排序参数。
|
||||
4. 排序参数映射:创建时间 -> `order_by:"CreatedTime"`;编辑时间 / 修改时间 -> `order_by:"EditedTime"`;升序 -> `direction:"ASC"`;降序 -> `direction:"DESC"`。不要省略排序参数后再用 Python / shell 客户端排序替代。
|
||||
5. 排序查询建议带 `page_size:200` 减少翻页;只有用户要求完整分页、递归盘点、大目录全量导出,或当前页返回 `has_more=true` 后继续翻页时,才加入 `page_token`。
|
||||
6. `page_size` 在分页、递归盘点或全量导出时建议显式设置为 `200`。如果服务端或环境返回参数错误,再降级到服务端允许的值,并记录降级原因。
|
||||
7. 调用前如果不确定字段结构,先运行 `lark-cli schema drive.files.list` 查看 `--params` 结构。
|
||||
|
||||
## 返回结构与解析
|
||||
|
||||
|
||||
@@ -47,4 +47,6 @@ JSON 输出包含以下字段:
|
||||
- `--url` 为必填参数
|
||||
- 当 `--url` 是 bare token(非完整 URL)时,`--type` 也是必填的
|
||||
- wiki URL 会自动调用 `get_node` API 解包,输出中 `type` 和 `token` 是底层文档的类型和 token
|
||||
- `+inspect` 只用于识别/消歧;如果任务已能通过 URL 路径形态完成路由判断,不必把它作为所有 Drive 操作的通用前置步骤
|
||||
- `+inspect` 失败后不要自动切到写接口继续尝试,先按错误提示处理权限、scope 或链接问题
|
||||
- 支持 `--dry-run` 查看将调用的 API 步骤
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
|
||||
如果用户只是想向文档 owner 申请访问权限,优先使用 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)。
|
||||
|
||||
## 公开权限修改前门槛
|
||||
|
||||
公开权限修改是高风险写操作。执行 `drive permission.public patch --yes` 前同时确认:
|
||||
|
||||
| 条件 | 可执行信号 |
|
||||
|------|------------|
|
||||
| 具体目标 | 单个 URL/token,或用户确认过的资源列表 |
|
||||
| 公开范围 | 用户明确选择组织内/互联网、可读/可编辑等具体 `link_share_entity` 档位 |
|
||||
| 执行确认 | 用户在本轮确认按该目标和范围执行 |
|
||||
|
||||
“开放一下”“共享给大家”“让大家能看”只表达目标状态,不包含具体公开范围。先列出可选范围并停止等待用户选择;公开档位必须来自用户选择,CLI 的 `--yes` 只表示已获得用户对该档位的执行确认。
|
||||
|
||||
## 公开权限错误码
|
||||
|
||||
调用 `lark-cli drive permission.public patch` 更新文档公开权限失败时,如果返回以下错误码,按表格给用户明确下一步。不要把这些错误简单归类为缺少 scope;它们通常表示租户、对外分享或文档密级策略拦截。
|
||||
|
||||
@@ -143,6 +143,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
| `permission_denied` | `1061004` / HTTP 403 | 当前身份无权操作目标资源 | 停止重试,检查目标文件夹权限、身份类型(user / bot)和资源可见性 |
|
||||
| `invalid_api_parameters` | `1061002` | API 参数被服务端拒绝 | 停止重试,检查 `--folder-token`、覆盖模式、`file_token`、文件名和上传参数;不要对同一参数组合批量重试 |
|
||||
| `parent_node_missing` | `1061044` | 上传 / 建目录使用的父文件夹不存在或当前身份不可见 | 停止重试,检查 `--folder-token` 是否仍存在、是否有权限、父目录是否在 push 过程中被删除;不要继续上传同一目录树 |
|
||||
| `parent_sibling_limit` | `1062507` | 目标父文件夹单层子节点数量超过上限 | 停止重试,清理目标目录、换一个 `--folder-token`,或把上传内容拆到多个子目录 |
|
||||
| `rate_limited` | `99991400` | 触发频控 | 停止当前批次,退避后再重试 |
|
||||
| `server_error` | `1061001` / `2200` | Drive 服务端异常 | 停止当前批次,稍后重试;保留 `log_id` 便于排查 |
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ lark-cli mail +watch --print-output-schema
|
||||
|------|------|------|
|
||||
| `--mailbox <id>` | `me` | 订阅目标邮箱 |
|
||||
| `--msg-format <mode>` | `metadata` | 输出模式:`metadata` / `minimal` / `plain_text_full` / `full` / `event` |
|
||||
| `--format <mode>` | `table` | 输出样式:`table` / `json` / `data` |
|
||||
| `--format <mode>` | `data` | 输出样式:`json`(带 ok/data 信封的 NDJSON 流)/ `data`(裸 NDJSON 流) |
|
||||
| `--folder-ids <json-array>` | — | 文件夹 ID 过滤,如 `["INBOX","SENT"]` |
|
||||
| `--folders <json-array>` | — | 文件夹名称过滤(与 `--folder-ids` 取并集) |
|
||||
| `--label-ids <json-array>` | — | 标签 ID 过滤,如 `["FLAGGED","IMPORTANT"]` |
|
||||
|
||||
Reference in New Issue
Block a user