Compare commits

..

21 Commits

Author SHA1 Message Date
liangshuo-1
781d188a60 chore: release v1.0.79 (#2082) 2026-07-28 21:02:37 +08:00
calendar-assistant
2e0fb9a880 docs(calendar): refine attendee guidance for bots and user-search identity (#2086)
Consolidate the user-search identity note into SKILL.md, and clarify bot
handling across attendee flows: bots are virtual identities with no
free/busy semantics, no meeting-room seat, and no room preference, so
they must be excluded from +suggestion, +room-find, and the scheduling
free/busy check. Note in create/update that bots remain valid attendees.
2026-07-28 20:34:09 +08:00
ILUO
927b37cd63 docs(task): document create data passthrough (#2080) 2026-07-28 20:26:35 +08:00
zhangjun-bytedance
d2e22c5fca feat: 0728 fix url (#2079) 2026-07-28 19:05:47 +08:00
ethan-zhx
fdae560014 docs(slides): add formula inline element syntax to quick-ref (#2077)
* docs(slides): add formula inline element syntax to quick-ref

* docs(slides): add chart gradient syntax to quick-ref
2026-07-28 17:40:54 +08:00
zhengzhijiej-tech
1b173e1953 fix(sheets): recognize OFL0X local office tokens (#2063) 2026-07-28 15:09:42 +08:00
ethan-zhx
57db1b3a8d feat(slides):update xsd (#2067) 2026-07-28 14:43:15 +08:00
calendar-assistant
4c1c5f5287 docs(calendar): clarify identity selection by event ownership (#2071)
Reframe the identity section around event ownership: use `--as user`
for the logged-in user's own events and `--as bot` for events the bot
creates or participates in, with matching `+agenda` examples.
2026-07-28 14:05:21 +08:00
liangshuo-1
3d2c10cd0b fix(ci): validate static workflow identity (#2015) 2026-07-27 19:39:11 +08:00
liangshuo-1
03de81c5f3 chore: release v1.0.78 (#2061) 2026-07-27 19:17:53 +08:00
yballul-bytedance
7abcaa7f68 feat(drive): add title+body joint search guidance and Top N pagination rules (#2059)
* feat(drive): add title+body joint search guidance and pagination rules for Top N results

- Add new blockquote explaining combined title+body search: use a single
  --query with both keywords instead of splitting into two searches
- Add rule for Top N results: N is an output cap, not --page-size; scan
  up to 3 pages filtering by title and summary_highlighted, read body
  only for title-matched candidates, stop early at N confirmed results
- Add quick-reference table row for folder-scoped title+body search
- Update pagination strategy rule to cover the 3-page cap for joint
  search in addition to the existing 5-page limit for other scenarios

* feat(drive): clarify Top N search output limit

* feat(drive): clarify search filters share one call

---------

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-27 17:19:48 +08:00
zhangjun-bytedance
8fb2476985 0727 fix rich text (#2062) 2026-07-27 16:17:08 +08:00
zhanghuanxu
56c9a2afd8 fix: exempt ghost text from slides lint 2026-07-27 11:59:04 +08:00
zhanghuanxu
2029189809 fix(slides):text may over flow shape 2026-07-27 11:59:04 +08:00
zhanghuanxu
ee427979a8 fix(slides): preserve info lint severity 2026-07-27 11:59:04 +08:00
zhanghuanxu
545abcbbde fix: refine character width estimation for lark-slides text lint
Replace the uniform 0.55em half-width coefficient with per-character-type
coefficients, add font-family awareness (sans/serif), bold multiplier,
letter-spacing support, and fix padding-aware line wrapping.

- Split half-width chars into uppercase (0.57), lowercase (0.51 sans / 0.53
  serif), digits (0.58), and punctuation (0.50)
- Add classify_font_family() to apply slightly wider lowercase widths for
  serif fonts (Georgia, Source Han Serif/思源宋体, Times, etc.)
- Add 5% width multiplier for bold text; detect <strong>/<b>/<i>/<em> tags
  and span-level bold/italic attributes in addition to content attrs
- Fix estimate_text_line_count_for_text to subtract paddingLeft/paddingRight
  from available width before computing wrap lines
- Add resolve_letter_spacing and wire letterSpacing through estimate_text_width
- Extract fontFamily/bold/italic/letterSpacing into element dict during parse
2026-07-27 11:59:04 +08:00
zhanghuanxu
4a73e83f1e fix(slides): allow chartParsedValues roundtrip tag
chartParsedValues is a server-injected roundtrip child tag under
chartField, not an attribute. Move it from ROUNDTRIP_SXSD_ATTRS to a
new ROUNDTRIP_SXSD_TAGS set and skip the tag (and its subtree) in the
SXSD tag whitelist check.
2026-07-27 11:59:04 +08:00
zhanghuanxu
7496420fa8 fix(slides): downgrade background-decoration text overflow to info
Large low-alpha text underneath other text shapes is typically a
background design element; treat text_may_overflow_shape as info in
that case instead of warning/error.
2026-07-27 11:59:04 +08:00
zhanghuanxu
43fabdf524 fix(slides): detect letterSpacing-driven text overflow
Extract letterSpacing from content/paragraph attrs and factor it into
width and line-count estimates, and stop short-circuiting the shape
overflow check for autoFit shapes so that letterSpacing-heavy captions
under normal-auto-fit no longer escape detection.
2026-07-27 11:59:04 +08:00
zhanghuanxu
8c46c74105 fix(slides): upgrade text overflow to error above 10px threshold
Text-shape overflow was always reported as a warning, which let clearly
broken pages pass the lint gate. Overflow > 10px now upgrades to error;
smaller overflows stay as warning to avoid flagging near-fit cases.
2026-07-27 11:59:04 +08:00
zhanghuanxu
70777c86c3 fix(slides): restrict canvas overflow checks 2026-07-27 11:59:04 +08:00
84 changed files with 2020 additions and 4018 deletions

View File

@@ -25,19 +25,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
@@ -253,19 +250,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");

View File

@@ -2,6 +2,40 @@
All notable changes to this project will be documented in this file.
## [v1.0.79] - 2026-07-28
### Features
- **slides**: update xsd (#2067)
### Bug Fixes
- **ci**: validate static workflow identity (#2015)
- **sheets**: recognize OFL0X local office tokens (#2063)
### Documentation
- **calendar**: clarify identity selection by event ownership (#2071)
- **slides**: add formula inline element syntax to quick-ref (#2077)
## [v1.0.78] - 2026-07-27
### Features
- event description support rich text (#1975)
### Bug Fixes
- **slides**: restrict canvas overflow checks
- **slides**: upgrade text overflow to error above 10px threshold
- **slides**: detect letterSpacing-driven text overflow
- **slides**: downgrade background-decoration text overflow to info
- **slides**: allow chartParsedValues roundtrip tag
- refine character width estimation for lark-slides text lint
- **slides**: preserve info lint severity
- **slides**: text may over flow shape
- exempt ghost text from slides lint
## [v1.0.77] - 2026-07-24
### Features
@@ -1667,6 +1701,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74

View File

@@ -386,7 +386,7 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
appInfoStub := &httpmock.Stub{
Method: http.MethodGet,
@@ -442,7 +442,7 @@ func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T)
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
@@ -485,18 +485,6 @@ type authScopesTokenResolver struct {
requests []credential.TokenSpec
}
type authTestAccountResolver struct {
appID string
}
func (r authTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID, Brand: core.BrandFeishu}, nil
}
func newAuthTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, authTestAccountResolver{appID: appID}, tokenResolver, nil)
}
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
r.requests = append(r.requests, req)
switch req.Type {

View File

@@ -27,9 +27,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
cmd := &cobra.Command{
Use: "status",
Short: "View current auth status",
Long: `Show OAuth user login, token validity, and granted scopes.
For token-validity checks, run lark-cli auth status --json --verify.
This is not profile/app selection diagnostics; use lark-cli whoami for the effective app/profile identity used by an invocation.`,
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)

View File

@@ -4,35 +4,15 @@
package auth
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAuthStatusHelpDistinguishesFromWhoami(t *testing.T) {
cmd := NewCmdAuthStatus(nil, nil)
for _, want := range []string{
"OAuth user login",
"auth status --json --verify",
"not profile/app selection diagnostics",
"lark-cli whoami",
} {
if !strings.Contains(cmd.Long, want) {
t.Errorf("auth status --help Long missing %q; got:\n%s", want, cmd.Long)
}
}
}
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
@@ -99,51 +79,6 @@ func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
}
}
type fixedStatusAccountResolver struct {
account *credential.Account
}
func (r *fixedStatusAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return r.account, nil
}
func TestAuthStatus_AllowsMatchingAppIDOnlySelectedProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "cli_a")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a",
AppId: "cli_a",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
config := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: "test-secret", Brand: core.BrandFeishu}
f, stdout, _, _ := cmdutil.TestFactory(t, config)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&envprovider.Provider{}},
&fixedStatusAccountResolver{account: credential.AccountFromCliConfig(config)},
nil,
nil,
).WithProfileFromFlag("tenant_a")
cmd := NewCmdAuth(f)
cmd.SetArgs([]string{"status", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status should use the selected built-in profile: %v", err)
}
if strings.Contains(stdout.String(), "credentials are provided externally") {
t.Fatalf("matching APP_ID-only env was misclassified as external:\n%s", stdout.String())
}
}
type statusOutput struct {
Identity string `json:"identity"`
Verified *bool `json:"verified"`

View File

@@ -6,10 +6,8 @@ package cmd
import (
"errors"
"io"
"os"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/envvars"
"github.com/spf13/pflag"
)
@@ -28,13 +26,5 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
return cmdutil.InvocationContext{}, err
}
profileFromFlag := fs.Changed("profile")
if !profileFromFlag {
globals.Profile = os.Getenv(envvars.CliProfile)
}
return cmdutil.InvocationContext{
Profile: globals.Profile,
ProfileFromFlag: profileFromFlag,
}, nil
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
}

View File

@@ -3,11 +3,7 @@
package cmd
import (
"testing"
"github.com/larksuite/cli/internal/envvars"
)
import "testing"
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
@@ -74,58 +70,3 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
}
}
func TestBootstrapProfileEnvFallback(t *testing.T) {
t.Run("flag wins over env", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"--profile", "tenant_flag", "whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "tenant_flag" {
t.Errorf("got %q, want tenant_flag", inv.Profile)
}
if !inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = false, want true")
}
})
t.Run("explicit empty flag clears env selection", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"--profile=", "whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "" {
t.Errorf("got %q, want empty", inv.Profile)
}
if !inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = false, want true")
}
})
t.Run("env used when flag absent", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "tenant_env" {
t.Errorf("got %q, want tenant_env", inv.Profile)
}
if inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = true, want false")
}
})
t.Run("empty when neither set", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "")
inv, err := BootstrapInvocationContext([]string{"whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "" {
t.Errorf("got %q, want empty", inv.Profile)
}
if inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = true, want false")
}
})
}

View File

@@ -84,16 +84,6 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
}
}
func TestConfigShowHelpClarifiesSavedConfig(t *testing.T) {
cmd := NewCmdConfigShow(nil, nil)
if !strings.Contains(cmd.Short, "saved config") {
t.Errorf("config show short = %q, want saved config", cmd.Short)
}
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
t.Errorf("config show help missing whoami route")
}
}
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -116,77 +106,6 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
}
}
// config show promises "saved config, not current usage" (help + skill
// routing): the session profile (--profile / LARKSUITE_CLI_PROFILE) must not
// change what it shows.
func TestConfigShowRun_IgnoresSessionProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{
{Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu},
{Name: "tenant_b", AppId: "cli_b", AppSecret: core.PlainSecret("your-secret-b"), Brand: core.BrandFeishu},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
f.Invocation.Profile = "tenant_b" // session selection must not leak in
if err := configShowRun(&ConfigShowOptions{Factory: f}); err != nil {
t.Fatalf("configShowRun: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"cli_a"`) || !strings.Contains(out, `"tenant_a"`) {
t.Fatalf("output = %s, want the saved default tenant_a/cli_a", out)
}
if strings.Contains(out, `"cli_b"`) {
t.Fatalf("output = %s, session profile tenant_b must not change saved-config view", out)
}
}
// engagedEnvStub simulates a fully engaged external credential provider.
type engagedEnvStub struct{}
func (engagedEnvStub) Name() string { return "env" }
func (engagedEnvStub) Priority() int { return 10 }
func (engagedEnvStub) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "cli_env", AppSecret: "your-password"}, nil // managed takeover
}
func (engagedEnvStub) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
// config show inspects the SAVED config only, so the parent command's
// external-credential gate must not apply: even with a fully engaged direct
// env credential, `config show` still answers from the saved config.
func TestConfigShow_BypassesExternalCredentialGate(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = credential.NewCredentialProvider([]extcred.Provider{engagedEnvStub{}}, nil, nil, nil)
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"show"})
if err := cmd.Execute(); err != nil {
t.Fatalf("config show must bypass the external-credential gate: %v", err)
}
if out := stdout.String(); !strings.Contains(out, `"cli_a"`) {
t.Fatalf("output = %s, want the saved config shown", out)
}
}
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
@@ -562,8 +481,7 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
}{
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
{"remove", []string{"remove"}},
// "show" is deliberately absent: it inspects the SAVED config only
// and bypasses this gate (TestConfigShow_BypassesExternalCredentialGate).
{"show", []string{"show"}},
{"default-as", []string{"default-as", "user"}},
{"strict-mode", []string{"strict-mode", "off"}},
}

View File

@@ -27,16 +27,7 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
cmd := &cobra.Command{
Use: "show",
Short: "Show saved config",
Long: "Shows saved config. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
// Override parent's RequireBuiltinCredentialProvider check: this
// command reads the SAVED config only (its own help promises "saved
// config, not current usage"), so the currently effective credential
// source — external or otherwise — must not gate it.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
Short: "Show current configuration",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
@@ -62,10 +53,7 @@ func configShowRun(opts *ConfigShowOptions) error {
if config == nil || len(config.Apps) == 0 {
return core.NotConfiguredError()
}
// Saved config only: the session profile (--profile / LARKSUITE_CLI_PROFILE)
// must not change what this command shows — the help and skill routing
// promise "saved config, not current usage" (use whoami for that).
app := config.CurrentAppConfig("")
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
}

View File

@@ -110,20 +110,8 @@ func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSp
return nil, errors.New("backend unavailable")
}
type eventTestAccountResolver struct {
appID string
}
func (r eventTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID}, nil
}
func newEventTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, eventTestAccountResolver{appID: appID}, tokenResolver, nil)
}
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
return &cmdutil.Factory{Credential: newEventTestCredentialProvider("cli_x", r)}
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
}
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {

View File

@@ -44,7 +44,7 @@ func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
client: &client.APIClient{
SDK: sdk,
ErrOut: io.Discard,
Credential: newEventTestCredentialProvider("test-app", &staticTokenResolver{}),
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
},
accessIdentity: core.AsBot,

View File

@@ -17,14 +17,11 @@ import (
)
// profileListItem is the JSON output for a single profile entry.
// `default` (formerly `active`, renamed in this feature as a declared
// breaking change) marks the saved default profile — never the identity
// effective for the current invocation; that is whoami's job.
type profileListItem struct {
Name string `json:"name"`
AppID string `json:"appId"`
Brand core.LarkBrand `json:"brand"`
Default bool `json:"default"`
Active bool `json:"active"`
User string `json:"user,omitempty"`
TokenStatus string `json:"tokenStatus,omitempty"`
}
@@ -33,8 +30,7 @@ type profileListItem struct {
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List saved profiles",
Long: "Lists saved profiles. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
Short: "List all profiles",
RunE: func(cmd *cobra.Command, args []string) error {
return profileListRun(f)
},
@@ -57,7 +53,7 @@ func profileListRun(f *cmdutil.Factory) error {
return nil
}
// Intentionally uses "" to show the saved default profile, not the ephemeral --profile override.
// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
currentApp := multi.CurrentAppConfig("")
currentName := ""
if currentApp != nil {
@@ -70,10 +66,10 @@ func profileListRun(f *cmdutil.Factory) error {
name := app.ProfileName()
item := profileListItem{
Name: name,
AppID: app.AppId,
Brand: app.Brand,
Default: name == currentName,
Name: name,
AppID: app.AppId,
Brand: app.Brand,
Active: name == currentName,
}
if len(app.Users) > 0 {

View File

@@ -14,17 +14,6 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Manage configuration profiles",
Long: `Profiles are named app identities managed by lark-cli.
Identity diagnostics and profile selection:
lark-cli whoami --json Show the app/profile lark-cli is using now.
lark-cli auth status --json --verify Verify OAuth login and token state.
--profile <name> Use a profile for this command only.
LARKSUITE_CLI_PROFILE Use a profile for the current shell / agent session.
config show / profile list Inspect saved config, not current usage.
unset LARKSUITE_CLI_PROFILE Clear the session profile and fall back to direct app env or configured default.
A selected profile takes precedence over matching direct env credentials and tokens.`,
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetTips(cmd, []string{

View File

@@ -306,24 +306,14 @@ func TestProfileListRun_OutputsProfiles(t *testing.T) {
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
}
raw := stdout.String()
// `active` is renamed to `default` as a declared breaking change: keeping
// a permanently mirrored alias would keep misleading agents into reading
// it as the currently effective identity (whoami's job).
if strings.Contains(raw, `"active"`) {
t.Fatalf("profile list output contains renamed active field: %s", raw)
}
if !strings.Contains(raw, `"default"`) {
t.Fatalf("profile list output missing default field: %s", raw)
}
if len(got) != 2 {
t.Fatalf("len(got) = %d, want 2", len(got))
}
if got[0].Name != "default" || !got[0].Default {
t.Fatalf("got[0] = %#v, want configured default profile", got[0])
if got[0].Name != "default" || !got[0].Active {
t.Fatalf("got[0] = %#v, want active default profile", got[0])
}
if got[1].Name != "target" || got[1].Default {
t.Fatalf("got[1] = %#v, want non-default target profile", got[1])
if got[1].Name != "target" || got[1].Active {
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
}
}
@@ -637,39 +627,6 @@ func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
})
}
// TestProfileHelpHasSelectionSection asserts `profile --help` documents the
// per-invocation flag and session-scoped env var for selecting a profile, so
// users and AI agents can find LARKSUITE_CLI_PROFILE without reading source.
func TestProfileHelpHasSelectionSection(t *testing.T) {
cmd := NewCmdProfile(nil)
if !strings.Contains(cmd.Long, "Identity diagnostics and profile selection:") {
t.Errorf("profile --help missing identity diagnostics and profile selection section")
}
if !strings.Contains(cmd.Long, "LARKSUITE_CLI_PROFILE") {
t.Errorf("profile --help missing LARKSUITE_CLI_PROFILE")
}
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
t.Errorf("profile --help missing whoami identity route")
}
if !strings.Contains(cmd.Long, "config show / profile list") {
t.Errorf("profile --help missing saved-config boundary")
}
const precedence = "A selected profile takes precedence over matching direct env credentials and tokens."
if !strings.Contains(cmd.Long, precedence) {
t.Errorf("profile --help missing precedence statement %q", precedence)
}
}
func TestProfileListHelpClarifiesSavedProfiles(t *testing.T) {
cmd := NewCmdProfileList(nil)
if !strings.Contains(cmd.Short, "saved profiles") {
t.Errorf("profile list short = %q, want saved profiles", cmd.Short)
}
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
t.Errorf("profile list help missing whoami route")
}
}
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
dir := setupProfileConfigDir(t)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {

View File

@@ -10,7 +10,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
)
@@ -34,15 +33,6 @@ type whoamiResult struct {
TokenStatus string `json:"tokenStatus"`
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
Hint string `json:"hint,omitempty"`
// CredentialSource, Explicit, and DirectCredentialEnv surface the cached
// credential.IdentitySelection computed during resolution (not re-inferred
// here). On the non-env extension-provider path CredentialSource is
// "extension:<provider>" (e.g. "extension:sidecar"); an empty value only
// means the selection was never resolved.
CredentialSource string `json:"credentialSource"`
Explicit bool `json:"explicit"`
DirectCredentialEnv credential.DirectCredentialEnv `json:"directCredentialEnv"`
}
// delegatedUser is the user a user-identity acts on behalf of.
@@ -68,10 +58,6 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current effective identity, app, profile, and token status (JSON)",
Long: `Show the effective app identity used by this invocation. This is not OAuth login status;
use ` + "`lark-cli auth status --json`" + ` for OAuth user/token state.
The JSON output includes credentialSource, appId, brand, and whether direct app credential
env is present and matches the selected profile.`,
RunE: func(cmd *cobra.Command, args []string) error {
return whoamiRun(cmd, opts)
},
@@ -111,17 +97,7 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
f.ResolveStrictMode(ctx).ForcedIdentity(),
)
diag := identitydiag.Diagnose(ctx, f, cfg, false)
// Read the cached selection computed during resolution; never re-infer it
// here. A resolution failure (e.g. under a non-env extension provider that
// doesn't populate a selection) degrades to the zero value rather than
// regressing whoami's own error/diagnostic path above.
var selection credential.IdentitySelection
if f.Credential != nil {
if sel, err := f.Credential.Selection(ctx); err == nil {
selection = sel
}
}
res := buildResult(cfg, as, source, diag, selection)
res := buildResult(cfg, as, source, diag)
output.PrintJson(f.IOStreams.Out, res)
return nil
}
@@ -146,23 +122,18 @@ func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, stri
// buildResult maps the resolved identity and local diagnostics into the output.
// ResolveAs only ever returns user or bot, so the default branch handles user.
// selection is the cached credential.IdentitySelection from resolution; it is
// read as-is, never recomputed.
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result, selection credential.IdentitySelection) *whoamiResult {
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
defaultAs := cfg.DefaultAs
if defaultAs == "" {
defaultAs = core.AsAuto
}
res := &whoamiResult{
Profile: cfg.ProfileName,
AppID: cfg.AppID,
Brand: cfg.Brand,
DefaultAs: string(defaultAs),
Identity: string(as),
IdentitySource: source,
CredentialSource: string(selection.Source),
Explicit: selection.Explicit(),
DirectCredentialEnv: selection.DirectCredentialEnv,
Profile: cfg.ProfileName,
AppID: cfg.AppID,
Brand: cfg.Brand,
DefaultAs: string(defaultAs),
Identity: string(as),
IdentitySource: source,
}
// Use the diagnosed hint as-is: it is tailored to the credential source, so
// it never says "auth login" when that is blocked under an external provider.

View File

@@ -15,13 +15,10 @@ import (
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/keychain"
)
func TestResolveSource(t *testing.T) {
@@ -55,7 +52,7 @@ func TestBuildResult_UserValid(t *testing.T) {
diag := identitydiag.Result{
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
@@ -80,7 +77,7 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
diag := identitydiag.Result{
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
@@ -103,7 +100,7 @@ func TestBuildResult_BotReady(t *testing.T) {
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: true, Status: "ready"},
}
r := buildResult(cfg, core.AsBot, "default_as", diag, credential.IdentitySelection{})
r := buildResult(cfg, core.AsBot, "default_as", diag)
if r.Identity != "bot" || r.IdentitySource != "default_as" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
@@ -124,7 +121,7 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
}
r := buildResult(cfg, core.AsBot, "auto_detect", diag, credential.IdentitySelection{})
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
@@ -321,94 +318,3 @@ func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
t.Fatalf("hint should explain external management: %q", got.Hint)
}
}
// noopWhoamiKeychain is a no-op KeychainAccess; the profile below uses a
// plaintext secret, so no keychain lookup is actually required.
type noopWhoamiKeychain struct{}
func (noopWhoamiKeychain) Get(service, account string) (string, error) { return "", nil }
func (noopWhoamiKeychain) Set(service, account, value string) error { return nil }
func (noopWhoamiKeychain) Remove(service, account string) error { return nil }
// credentialSourceSecret is the profile secret written to config for
// TestWhoamiIncludesCredentialSource. It must never leak into whoami's output
// (security: never leak a secret).
const credentialSourceSecret = "test-secret"
// profileSelectionFactory builds a Factory whose CredentialProvider resolves
// an explicit profile ("tenant_a") supplied via the LARKSUITE_CLI_PROFILE env
// fallback (not --profile), so Selection().Source resolves to
// env:LARKSUITE_CLI_PROFILE and Explicit() is true, with no direct
// app-credential env vars present.
func profileSelectionFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) {
t.Helper()
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a",
AppId: "cli_a",
AppSecret: core.PlainSecret(credentialSourceSecret),
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return noopWhoamiKeychain{} }, "tenant_a")
cred := credential.NewCredentialProvider([]extcred.Provider{&envprovider.Provider{}}, defaultAcct, nil, nil)
cred.WithProfileFromEnv("tenant_a")
cfg := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: credentialSourceSecret, Brand: core.BrandFeishu}
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
return f, out
}
// TestWhoamiIncludesCredentialSource locks in the diagnostic fields surfaced
// from the cached credential.IdentitySelection: credentialSource,
// explicit, and directCredentialEnv. whoami must read the cached selection
// as-is, not re-infer it.
func TestWhoamiIncludesCredentialSource(t *testing.T) {
f, out := profileSelectionFactory(t)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
raw := out.String()
if strings.Contains(raw, credentialSourceSecret) {
t.Fatalf("whoami output leaked the profile secret: %s", raw)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, raw)
}
if got.CredentialSource != string(credential.SourceEnvProfile) {
t.Fatalf("credentialSource = %q, want %q", got.CredentialSource, credential.SourceEnvProfile)
}
if !got.Explicit {
t.Fatalf("explicit = false, want true")
}
if got.DirectCredentialEnv.Present {
t.Fatalf("directCredentialEnv.present = true, want false: %#v", got.DirectCredentialEnv)
}
if !strings.Contains(raw, `"credentialSource": "env:LARKSUITE_CLI_PROFILE"`) {
t.Fatalf("raw JSON missing credentialSource literal: %s", raw)
}
if got.DirectCredentialEnv.Present || len(got.DirectCredentialEnv.Keys) != 0 ||
got.DirectCredentialEnv.AppID != "" || got.DirectCredentialEnv.Matched || got.DirectCredentialEnv.ConflictsWithProfile {
t.Fatalf("directCredentialEnv = %#v, want only present:false set", got.DirectCredentialEnv)
}
}

View File

@@ -67,17 +67,6 @@ Typed errors render to **stderr** as one JSON object per process exit:
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
Credential/identity-selection extension fields (per-Subtype-stable):
| Field | Carrier | Subtypes | Notes |
|-------|---------|----------|-------|
| `missing_keys` | `ConfigError` | `app_credential_incomplete` | env var NAMES that must all be set; never values |
| `required_any_of` | `ConfigError` | `app_credential_incomplete` | env var NAMES where any one completes the credential; mutually exclusive with `missing_keys` |
| `profile` | `ConfigError` | `profile_not_found`, `profile_secret_invalid` | requested profile name |
| `app_id` | `ConfigError` | `profile_secret_invalid` | plaintext app id; never a secret |
| `credential_source` | `ConfigError` | `profile_not_found`, `no_active_profile` | how the identity was (not) chosen: `flag:--profile` \| `env:LARKSUITE_CLI_PROFILE` \| `config` |
| `profile_app_id`, `env_app_id` | `ValidationError` | `profile_app_credential_conflict` | the two conflicting plaintext app ids |
`SecurityPolicyError` renders through the same typed envelope as every
other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via

View File

@@ -136,79 +136,6 @@ func TestConfigError_MarshalJSON(t *testing.T) {
}
}
func TestConfigError_ProfileFieldsMarshalJSON(t *testing.T) {
ce := NewConfigError(SubtypeAppCredentialIncomplete, "incomplete").
WithMissingKeys("LARKSUITE_CLI_APP_ID", "LARKSUITE_CLI_APP_SECRET").
WithRequiredAnyOf("LARKSUITE_CLI_APP_SECRET", "LARKSUITE_CLI_USER_ACCESS_TOKEN").
WithProfile("work").
WithAppID("cli_abc").
WithCredentialSource("flag:--profile")
b, err := json.Marshal(ce)
if err != nil {
t.Fatal(err)
}
s := string(b)
for _, want := range []string{
`"type":"config"`,
`"subtype":"app_credential_incomplete"`,
`"missing_keys":["LARKSUITE_CLI_APP_ID","LARKSUITE_CLI_APP_SECRET"]`,
`"required_any_of":["LARKSUITE_CLI_APP_SECRET","LARKSUITE_CLI_USER_ACCESS_TOKEN"]`,
`"profile":"work"`,
`"app_id":"cli_abc"`,
`"credential_source":"flag:--profile"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// omitempty: unset fields must not appear on the wire.
empty := NewConfigError(SubtypeProfileNotFound, "x")
b2, err := json.Marshal(empty)
if err != nil {
t.Fatal(err)
}
s2 := string(b2)
for _, notWant := range []string{`"missing_keys"`, `"required_any_of"`, `"profile"`, `"app_id"`, `"credential_source"`} {
if strings.Contains(s2, notWant) {
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
}
}
}
func TestValidationError_ProfileConflictMarshalJSON(t *testing.T) {
ve := NewValidationError(SubtypeProfileAppCredentialConflict, "conflict").
WithProfileAppConflict("cli_profile", "cli_env")
b, err := json.Marshal(ve)
if err != nil {
t.Fatal(err)
}
s := string(b)
for _, want := range []string{
`"type":"validation"`,
`"subtype":"profile_app_credential_conflict"`,
`"profile_app_id":"cli_profile"`,
`"env_app_id":"cli_env"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// omitempty: unset conflict fields must not appear on the wire.
empty := NewValidationError(SubtypeInvalidArgument, "x")
b2, err := json.Marshal(empty)
if err != nil {
t.Fatal(err)
}
s2 := string(b2)
for _, notWant := range []string{`"profile_app_id"`, `"env_app_id"`} {
if strings.Contains(s2, notWant) {
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
}
}
}
func TestNetworkError_MarshalJSON(t *testing.T) {
ne := &NetworkError{
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},

View File

@@ -12,9 +12,8 @@ const (
// CategoryValidation subtypes
const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
SubtypeProfileAppCredentialConflict Subtype = "profile_app_credential_conflict" // profile and direct app env both set but app_id differs
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
)
// CategoryAuthentication subtypes
@@ -42,13 +41,9 @@ const (
// CategoryConfig subtypes
const (
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
SubtypeProfileNotFound Subtype = "profile_not_found" // --profile / LARKSUITE_CLI_PROFILE points to a nonexistent profile
SubtypeNoActiveProfile Subtype = "no_active_profile" // no active identity input and no usable default profile
SubtypeAppCredentialIncomplete Subtype = "app_credential_incomplete" // direct app env missing app_id or app_secret
SubtypeProfileSecretInvalid Subtype = "profile_secret_invalid" // profile exists but its secret cannot be resolved locally
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
)
// CategoryNetwork subtypes

View File

@@ -61,11 +61,9 @@ type TypedError interface {
// it is intentionally not serialized.
type ValidationError struct {
Problem
Param string `json:"param,omitempty"`
Params []InvalidParam `json:"params,omitempty"`
ProfileAppID string `json:"profile_app_id,omitempty"`
EnvAppID string `json:"env_app_id,omitempty"`
Cause error `json:"-"`
Param string `json:"param,omitempty"`
Params []InvalidParam `json:"params,omitempty"`
Cause error `json:"-"`
}
// InvalidParam is one structured validation diagnostic: the parameter that
@@ -152,12 +150,6 @@ func (e *ValidationError) WithCause(cause error) *ValidationError {
return e
}
func (e *ValidationError) WithProfileAppConflict(profileAppID, envAppID string) *ValidationError {
e.ProfileAppID = profileAppID
e.EnvAppID = envAppID
return e
}
// =========================== AuthenticationError =============================
// AuthenticationError is the typed error for CategoryAuthentication.
@@ -323,18 +315,8 @@ func (e *PermissionError) WithCause(cause error) *PermissionError {
// intentionally not serialized.
type ConfigError struct {
Problem
Field string `json:"field,omitempty"`
MissingKeys []string `json:"missing_keys,omitempty"`
RequiredAnyOf []string `json:"required_any_of,omitempty"`
Profile string `json:"profile,omitempty"`
AppID string `json:"app_id,omitempty"`
// CredentialSource is the machine-readable App/credential selection source
// that produced this config error (e.g. "flag:--profile",
// "env:LARKSUITE_CLI_PROFILE", "config"). It is required on
// profile_not_found and no_active_profile so an agent can branch
// on how the identity was (or was not) chosen. It is never a secret.
CredentialSource string `json:"credential_source,omitempty"`
Cause error `json:"-"`
Field string `json:"field,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
@@ -388,34 +370,6 @@ func (e *ConfigError) WithField(field string) *ConfigError {
return e
}
func (e *ConfigError) WithMissingKeys(keys ...string) *ConfigError {
e.MissingKeys = slices.Clone(keys)
return e
}
func (e *ConfigError) WithRequiredAnyOf(keys ...string) *ConfigError {
e.RequiredAnyOf = slices.Clone(keys)
return e
}
func (e *ConfigError) WithProfile(name string) *ConfigError {
e.Profile = name
return e
}
func (e *ConfigError) WithAppID(appID string) *ConfigError {
e.AppID = appID
return e
}
// WithCredentialSource records the machine-readable credential-selection source
// on the wire (snake_case credential_source). The value is an enum string
// (e.g. "flag:--profile", "config"), never a secret.
func (e *ConfigError) WithCredentialSource(source string) *ConfigError {
e.CredentialSource = source
return e
}
func (e *ConfigError) WithCause(cause error) *ConfigError {
e.Cause = cause
return e

View File

@@ -643,29 +643,3 @@ func TestBuilderSetter_DefensiveCopy(t *testing.T) {
}
})
}
// ======================= Profile selection error subtypes =======================
func TestConfigErrorProfileFields(t *testing.T) {
e := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "incomplete").
WithMissingKeys("LARKSUITE_CLI_APP_ID").
WithCredentialSource("env:LARKSUITE_CLI_PROFILE")
p, ok := errs.ProblemOf(e)
if !ok || p.Subtype != errs.SubtypeAppCredentialIncomplete {
t.Fatalf("subtype mismatch: %+v", p)
}
if len(e.MissingKeys) != 1 || e.MissingKeys[0] != "LARKSUITE_CLI_APP_ID" {
t.Errorf("missing_keys not set: %v", e.MissingKeys)
}
if e.CredentialSource != "env:LARKSUITE_CLI_PROFILE" {
t.Errorf("credential_source not set: %q", e.CredentialSource)
}
}
func TestValidationErrorProfileConflict(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict, "conflict").
WithProfileAppConflict("cli_profile", "cli_env")
if e.ProfileAppID != "cli_profile" || e.EnvAppID != "cli_env" {
t.Errorf("conflict fields not set: %q %q", e.ProfileAppID, e.EnvAppID)
}
}

View File

@@ -23,89 +23,63 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
appSecret := os.Getenv(envvars.CliAppSecret)
hasUAT := os.Getenv(envvars.CliUserAccessToken) != ""
hasTAT := os.Getenv(envvars.CliTenantAccessToken) != ""
presentKeys := presentCredentialEnvKeys(appID, appSecret, hasUAT, hasTAT)
if len(presentKeys) == 0 {
return nil, nil
if appID == "" && appSecret == "" {
switch {
case hasUAT:
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"}
case hasTAT:
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"}
default:
return nil, nil
}
}
if appID == "" {
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"}
}
if appSecret == "" && !hasUAT && !hasTAT {
return nil, &credential.BlockError{
Provider: "env",
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
}
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
// Identity policy variables are validated whenever a direct credential
// input is present. Their errors must not be hidden by a later credential
// completeness check or profile arbitration.
defaultAs := credential.Identity(os.Getenv(envvars.CliDefaultAs))
switch defaultAs {
case "", credential.IdentityAuto, credential.IdentityUser, credential.IdentityBot:
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
case "", credential.IdentityAuto:
acct.DefaultAs = id
case credential.IdentityUser, credential.IdentityBot:
acct.DefaultAs = id
default:
return nil, &credential.BlockError{
Provider: "env",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, defaultAs),
Code: credential.BlockReasonInvalidPolicy,
Param: envvars.CliDefaultAs,
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
}
}
strictMode := os.Getenv(envvars.CliStrictMode)
var supported credential.IdentitySupport
switch strictMode {
// Explicit strict mode policy takes priority
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
case "bot":
supported = credential.SupportsBot
acct.SupportedIdentities = credential.SupportsBot
case "user":
supported = credential.SupportsUser
acct.SupportedIdentities = credential.SupportsUser
case "off":
supported = credential.SupportsAll
acct.SupportedIdentities = credential.SupportsAll
case "":
// Infer from available tokens
if hasUAT {
supported |= credential.SupportsUser
acct.SupportedIdentities |= credential.SupportsUser
}
if hasTAT {
supported |= credential.SupportsBot
acct.SupportedIdentities |= credential.SupportsBot
}
default:
return nil, &credential.BlockError{
Provider: "env",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
Code: credential.BlockReasonInvalidPolicy,
Param: envvars.CliStrictMode,
}
}
if appID == "" && appSecret == "" {
switch {
case hasUAT:
return nil, incompleteCredentialError(
appID,
envvars.CliUserAccessToken+" is set but "+envvars.CliAppID+" is missing",
[]string{envvars.CliAppID}, nil, presentKeys)
case hasTAT:
return nil, incompleteCredentialError(
appID,
envvars.CliTenantAccessToken+" is set but "+envvars.CliAppID+" is missing",
[]string{envvars.CliAppID}, nil, presentKeys)
}
}
if appID == "" {
return nil, incompleteCredentialError(
appID,
envvars.CliAppSecret+" is set but "+envvars.CliAppID+" is missing",
[]string{envvars.CliAppID}, nil, presentKeys)
}
if appSecret == "" && !hasUAT && !hasTAT {
return nil, incompleteCredentialError(
appID,
envvars.CliAppID+" is set but no app secret or access token is available",
nil,
[]string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken},
presentKeys)
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
acct := &credential.Account{
AppID: appID,
AppSecret: appSecret,
Brand: brand,
DefaultAs: defaultAs,
SupportedIdentities: supported,
Kind: credential.AccountDirect,
}
if acct.DefaultAs == "" {
switch {
case hasUAT:
@@ -118,35 +92,6 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
return acct, nil
}
func incompleteCredentialError(appID, reason string, missingKeys, requiredAnyOf, presentKeys []string) *credential.BlockError {
return &credential.BlockError{
Provider: "env",
Reason: reason,
Code: credential.BlockReasonCredentialIncomplete,
MissingKeys: missingKeys,
RequiredAnyOf: requiredAnyOf,
PresentKeys: presentKeys,
AppID: appID,
}
}
func presentCredentialEnvKeys(appID, appSecret string, hasUAT, hasTAT bool) []string {
var keys []string
if appID != "" {
keys = append(keys, envvars.CliAppID)
}
if appSecret != "" {
keys = append(keys, envvars.CliAppSecret)
}
if hasUAT {
keys = append(keys, envvars.CliUserAccessToken)
}
if hasTAT {
keys = append(keys, envvars.CliTenantAccessToken)
}
return keys
}
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
var envKey string
switch req.Type {

View File

@@ -6,7 +6,6 @@ package env
import (
"context"
"errors"
"slices"
"strings"
"testing"
@@ -48,22 +47,6 @@ func TestResolveAccount_OnlyIDSet(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
if blockErr.Code != credential.BlockReasonCredentialIncomplete {
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonCredentialIncomplete)
}
want := []string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken}
if !slices.Equal(blockErr.RequiredAnyOf, want) {
t.Fatalf("RequiredAnyOf = %v, want %v", blockErr.RequiredAnyOf, want)
}
if len(blockErr.MissingKeys) != 0 {
t.Fatalf("MissingKeys = %v, want empty", blockErr.MissingKeys)
}
if !slices.Equal(blockErr.PresentKeys, []string{envvars.CliAppID}) {
t.Fatalf("PresentKeys = %v, want [%s]", blockErr.PresentKeys, envvars.CliAppID)
}
if blockErr.AppID != "cli_test" {
t.Fatalf("AppID = %q, want cli_test", blockErr.AppID)
}
}
func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
@@ -92,81 +75,18 @@ func TestResolveAccount_OnlySecretSet(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
if blockErr.Code != credential.BlockReasonCredentialIncomplete ||
!slices.Equal(blockErr.MissingKeys, []string{envvars.CliAppID}) ||
!slices.Equal(blockErr.PresentKeys, []string{envvars.CliAppSecret}) {
t.Fatalf("BlockError = %+v, want incomplete with missing APP_ID and present APP_SECRET", blockErr)
}
if len(blockErr.RequiredAnyOf) != 0 {
t.Fatalf("RequiredAnyOf = %v, want empty for APP_SECRET-only", blockErr.RequiredAnyOf)
}
}
func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) {
for _, tt := range []struct {
name string
key string
}{
{name: "UAT", key: envvars.CliUserAccessToken},
{name: "TAT", key: envvars.CliTenantAccessToken},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv(tt.key, "token_test")
t.Setenv(envvars.CliUserAccessToken, "uat_test")
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
if !strings.Contains(err.Error(), envvars.CliAppID) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
}
if blockErr.Code != credential.BlockReasonCredentialIncomplete ||
!slices.Equal(blockErr.MissingKeys, []string{envvars.CliAppID}) ||
!slices.Equal(blockErr.PresentKeys, []string{tt.key}) {
t.Fatalf("BlockError = %+v, want incomplete for %s", blockErr, tt.key)
}
if len(blockErr.RequiredAnyOf) != 0 {
t.Fatalf("RequiredAnyOf = %v, want empty for %s-only", blockErr.RequiredAnyOf, tt.name)
}
})
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
}
func TestResolveAccount_InvalidPolicyRejectedBeforeIncomplete(t *testing.T) {
for _, tt := range []struct {
name string
key string
}{
{name: "DEFAULT_AS", key: envvars.CliDefaultAs},
{name: "STRICT_MODE", key: envvars.CliStrictMode},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv(tt.key, "banana")
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("error = %T %v, want BlockError", err, err)
}
if blockErr.Code != credential.BlockReasonInvalidPolicy {
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonInvalidPolicy)
}
if blockErr.Param != tt.key {
t.Fatalf("Param = %q, want %q", blockErr.Param, tt.key)
}
if !strings.Contains(blockErr.Reason, tt.key) {
t.Fatalf("reason = %q, want %s", blockErr.Reason, tt.key)
}
})
if !strings.Contains(err.Error(), envvars.CliAppID) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
}
}
@@ -338,9 +258,6 @@ func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %T", err)
}
if blockErr.Code != credential.BlockReasonInvalidPolicy || blockErr.Param != envvars.CliStrictMode {
t.Fatalf("BlockError = %+v, want invalid_policy with Param %s", blockErr, envvars.CliStrictMode)
}
if !strings.Contains(err.Error(), envvars.CliStrictMode) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode)
}
@@ -359,9 +276,6 @@ func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %T", err)
}
if blockErr.Code != credential.BlockReasonInvalidPolicy || blockErr.Param != envvars.CliDefaultAs {
t.Fatalf("BlockError = %+v, want invalid_policy with Param %s", blockErr, envvars.CliDefaultAs)
}
if !strings.Contains(err.Error(), envvars.CliDefaultAs) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
}

View File

@@ -77,8 +77,6 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
Code: credential.BlockReasonInvalidPolicy,
Param: envvars.CliDefaultAs,
}
}
@@ -94,8 +92,6 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
Code: credential.BlockReasonInvalidPolicy,
Param: envvars.CliStrictMode,
}
}

View File

@@ -7,9 +7,7 @@ package sidecar
import (
"context"
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
@@ -148,57 +146,6 @@ func TestResolveAccount_StrictMode(t *testing.T) {
}
}
func TestResolveAccount_InvalidPolicyClassified(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test")
tests := []struct {
name string
key string
value string
supportedText string
}{
{
name: "default as",
key: envvars.CliDefaultAs,
value: "banana",
supportedText: "want user, bot, or auto",
},
{
name: "strict mode",
key: envvars.CliStrictMode,
value: "banana",
supportedText: "want bot, user, or off",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
unsetEnv(t, envvars.CliDefaultAs)
unsetEnv(t, envvars.CliStrictMode)
setEnv(t, tt.key, tt.value)
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("error = %T %v, want BlockError", err, err)
}
if blockErr.Code != credential.BlockReasonInvalidPolicy {
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonInvalidPolicy)
}
if blockErr.Param != tt.key {
t.Fatalf("Param = %q, want %q", blockErr.Param, tt.key)
}
if !strings.Contains(blockErr.Reason, tt.key) ||
!strings.Contains(blockErr.Reason, tt.value) ||
!strings.Contains(blockErr.Reason, tt.supportedText) {
t.Fatalf("Reason = %q, want variable, invalid value, and supported values", blockErr.Reason)
}
})
}
}
func TestResolveToken_NotActive(t *testing.T) {
unsetEnv(t, envvars.CliAuthProxy)

View File

@@ -44,27 +44,6 @@ func (s IdentitySupport) UserOnly() bool { return s == SupportsUser }
// BotOnly returns true if only bot identity is supported.
func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
// AccountKind declares how an account participates in credential arbitration.
type AccountKind int
const (
// AccountManaged means the provider owns the whole identity; winning it
// ends arbitration outright. The zero value, so existing providers are
// unchanged.
AccountManaged AccountKind = iota
// AccountDirect marks an actively supplied raw credential (the env
// provider's LARKSUITE_CLI_* variables). It participates in profile
// arbitration and conflict detection instead of winning outright.
//
// RESERVED: only the builtin env provider may declare AccountDirect
// today — the arbitration's direct-credential diagnostics are defined in
// terms of the process environment, and the caller rejects AccountDirect
// from any other provider. Third-party providers must return
// AccountManaged until the SPI carries provider-reported input
// descriptors.
AccountDirect
)
// Account holds resolved app credentials and configuration.
type Account struct {
AppID string
@@ -74,7 +53,6 @@ type Account struct {
ProfileName string
OpenID string // optional; if UAT is available, API result takes precedence
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
Kind AccountKind // AccountManaged (default) or AccountDirect
}
// Token holds a resolved access token and optional metadata.
@@ -98,38 +76,11 @@ type TokenSpec struct {
AppID string
}
// BlockReason classifies provider-originated block conditions that callers may
// safely map to a more specific public error contract.
type BlockReason string
const (
// BlockReasonCredentialIncomplete marks incomplete inputs from the builtin
// process-env credential provider. It is reserved for that provider because
// direct-credential arbitration and diagnostics currently name the fixed
// LARKSUITE_CLI_* env surface. Third-party providers must return an
// unclassified BlockError until the SPI carries provider-owned input
// descriptors. Blocks without a Code propagate unchanged.
BlockReasonCredentialIncomplete BlockReason = "credential_incomplete"
// BlockReasonInvalidPolicy marks a user-supplied policy input (e.g.
// LARKSUITE_CLI_DEFAULT_AS / LARKSUITE_CLI_STRICT_MODE) that failed
// validation. The caller maps it to a typed validation error carrying
// Param and a repair hint, so user input mistakes never surface as
// internal errors.
BlockReasonInvalidPolicy BlockReason = "invalid_policy"
)
// BlockError is returned by a Provider to actively reject a request
// and prevent subsequent providers in the chain from being consulted.
type BlockError struct {
Provider string
Reason string
Code BlockReason
MissingKeys []string // environment variable names only; never values
RequiredAnyOf []string // environment variable names only; never values
PresentKeys []string // environment variable names only; never values
AppID string // plaintext app identifier used only for source comparison; never a secret
Param string // name of the invalid input variable on invalid_policy blocks; never a value
Provider string
Reason string
}
func (e *BlockError) Error() string {

View File

@@ -48,18 +48,6 @@ func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.Token
return &credential.TokenResult{Token: "test-token"}, nil
}
type clientTestAccountResolver struct {
appID string
}
func (r clientTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID, Brand: core.BrandFeishu}, nil
}
func newClientTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, clientTestAccountResolver{appID: appID}, tokenResolver, nil)
}
// newTestAPIClient creates an APIClient with a mock HTTP transport.
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
t.Helper()
@@ -70,7 +58,7 @@ func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Bu
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(httpClient),
)
testCred := newClientTestCredentialProvider("test-app", &staticTokenResolver{})
testCred := credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil)
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
return &APIClient{
SDK: sdk,
@@ -475,7 +463,7 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{Timeout: 5 * time.Millisecond},
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
@@ -510,7 +498,7 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
})
ac := &APIClient{
HTTP: &http.Client{Transport: rt},
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
@@ -544,7 +532,7 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
@@ -584,7 +572,7 @@ func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.Tok
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: newClientTestCredentialProvider("test-app", &needAuthTokenResolver{userOpenID: "ou_test_user"}),
Credential: credential.NewCredentialProvider(nil, nil, &needAuthTokenResolver{userOpenID: "ou_test_user"}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
@@ -624,7 +612,7 @@ func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *t
func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}

View File

@@ -27,11 +27,6 @@ import (
// In tests, replace any field to stub out external dependencies.
type InvocationContext struct {
Profile string
// ProfileFromFlag is true when Profile was set via the --profile flag,
// and false when it came from the LARKSUITE_CLI_PROFILE env fallback
// (or neither was set). Downstream credential resolution uses this to
// report the correct profile source.
ProfileFromFlag bool
}
type Factory struct {

View File

@@ -63,11 +63,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
f.Credential = buildCredentialProvider(credentialDeps{
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
ProfileFromFlag: inv.ProfileFromFlag,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Runtime config contains resolved account data only.
@@ -175,11 +174,10 @@ func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
}
type credentialDeps struct {
Keychain func() keychain.KeychainAccess
Profile string
ProfileFromFlag bool
HttpClient func() (*http.Client, error)
ErrOut io.Writer
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
}
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
@@ -192,13 +190,5 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
// depend on. enrichUserInfo failures are already non-fatal (the
// provider clears unverified identity fields), so silencing the
// warning is safe.
cred := credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
if deps.Profile == "" {
// No profile selected — don't record a phantom env source.
return cred
}
if deps.ProfileFromFlag {
return cred.WithProfileFromFlag(deps.Profile)
}
return cred.WithProfileFromEnv(deps.Profile)
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
@@ -406,14 +405,6 @@ type stubExtProvider struct {
err error
}
type stubDefaultAccountResolver struct {
acct *credential.Account
}
func (s *stubDefaultAccountResolver) ResolveAccount(_ context.Context) (*credential.Account, error) {
return s.acct, nil
}
func (s *stubExtProvider) Name() string { return s.name }
func (s *stubExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
return s.acct, s.err
@@ -457,86 +448,6 @@ func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
}
}
func TestRequireBuiltinCredentialProvider_AllowsMatchingAppIDOnlyProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "cli_a")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a",
AppId: "cli_a",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
cred := credential.NewCredentialProvider(
[]extcred.Provider{&envprovider.Provider{}},
&stubDefaultAccountResolver{acct: &credential.Account{AppID: "cli_a", AppSecret: "test-secret"}},
nil,
nil,
).WithProfileFromFlag("tenant_a")
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
if err := f.RequireBuiltinCredentialProvider(context.Background(), "auth"); err != nil {
t.Fatalf("matching APP_ID-only profile should use builtin credentials: %v", err)
}
}
// A stale LARKSUITE_CLI_PROFILE (profile that cannot resolve) must not lock
// the user out of the builtin setup/repair commands this gate guards: the
// probe falls back to provider engagement and lets the command run.
func TestRequireBuiltinCredentialProvider_StaleProfileDoesNotLockOut(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) // no config -> "ghost" cannot resolve
stub := &stubExtProvider{name: "env"} // not engaged: returns nil, nil
cred := credential.NewCredentialProvider(
[]extcred.Provider{stub},
&stubDefaultAccountResolver{},
nil,
nil,
).WithProfileFromEnv("ghost")
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
if err := f.RequireBuiltinCredentialProvider(context.Background(), "config"); err != nil {
t.Fatalf("stale profile must not lock out builtin auth/config commands: %v", err)
}
}
// An invalid policy variable (e.g. LARKSUITE_CLI_DEFAULT_AS=banana) is a user
// input error, not an external credential takeover: the gate surfaces the
// same typed validation error as formal arbitration instead of a misleading
// "provided externally" refusal.
func TestRequireBuiltinCredentialProvider_InvalidPolicySurfacesTypedError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
stub := &stubExtProvider{name: "env", err: &extcred.BlockError{
Provider: "env",
Reason: "invalid LARKSUITE_CLI_DEFAULT_AS \"banana\" (want user, bot, or auto)",
Code: extcred.BlockReasonInvalidPolicy,
Param: envvars.CliDefaultAs,
}}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, &stubDefaultAccountResolver{}, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
prob, ok := errs.ProblemOf(err)
if !ok || prob.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("err = %v, want typed invalid_argument (same as formal arbitration)", err)
}
if strings.Contains(err.Error(), "provided externally") {
t.Fatalf("err = %v, must not read as external takeover", err)
}
}
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, nil)
f.Credential = nil

View File

@@ -255,11 +255,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
}
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
// invalid_config, not not_configured: the config exists but is
// internally inconsistent. not_configured would let callers degrade
// this into a generic "secret invalid" answer and destroy the precise
// repair hint (which names the expected keychain key — never a value).
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "appId and appSecret keychain key are out of sync").
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
WithHint("%s", err.Error()).
WithCause(err)
}

View File

@@ -36,13 +36,16 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
if errors.Is(err, os.ErrNotExist) {
return nil, NotConfiguredError()
}
// Surface the real cause so the user can fix the broken file. Every
// non-ENOENT load failure — malformed JSON, permission denied, I/O
// error — means a config EXISTS but cannot be used: invalid_config.
// Only a genuinely absent config is not_configured; anything else
// classified as not_configured would let callers degrade it into
// profile_not_found / no_active_profile and hide the real cause.
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
// Surface the real cause (parse error, permission denied, etc.)
// so the user can fix the broken file. A malformed file is
// invalid_config; anything else (permission denied, etc.) is
// not_configured. Both stay on the typed structured-envelope path
// at the root command's error sink.
subtype := errs.SubtypeNotConfigured
if isMalformedConfigError(err) {
subtype = errs.SubtypeInvalidConfig
}
return nil, errs.NewConfigError(subtype, "failed to load config: %v", err).WithCause(err)
}
if multi == nil || len(multi.Apps) == 0 {
return nil, NotConfiguredError()

View File

@@ -1,154 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package credential_test
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
sidecarprovider "github.com/larksuite/cli/extension/credential/sidecar"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/sidecar"
)
func newRealSidecarCredentialProvider(t *testing.T) *credential.CredentialProvider {
t.Helper()
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
t.Setenv(envvars.CliProxyKey, "test-key")
t.Setenv(envvars.CliAppID, "cli_sidecar")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
return credential.NewCredentialProvider(
[]extcred.Provider{&sidecarprovider.Provider{}},
nil,
nil,
nil,
)
}
func TestAuthSidecarInvalidPolicyUsesValidationContract(t *testing.T) {
for _, tt := range []struct {
name string
key string
}{
{name: "default as", key: envvars.CliDefaultAs},
{name: "strict mode", key: envvars.CliStrictMode},
} {
t.Run(tt.name, func(t *testing.T) {
cp := newRealSidecarCredentialProvider(t)
t.Setenv(tt.key, "banana")
_, err := cp.ResolveAccount(context.Background())
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed validation error", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want ValidationError", err, err)
}
if validationErr.Param != tt.key {
t.Fatalf("param = %q, want %q", validationErr.Param, tt.key)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(problem.Hint, tt.key) {
t.Fatalf("hint = %q, want variable name %s", problem.Hint, tt.key)
}
var blockErr *extcred.BlockError
if !errors.As(err, &blockErr) ||
blockErr.Code != extcred.BlockReasonInvalidPolicy ||
blockErr.Param != tt.key {
t.Fatalf("cause = %T %v, want classified BlockError for %s", err, err, tt.key)
}
})
}
}
func TestAuthSidecarGateProbeUsesValidationContract(t *testing.T) {
cp := newRealSidecarCredentialProvider(t)
t.Setenv(envvars.CliStrictMode, "banana")
name, err := cp.ActiveExtensionProviderName(context.Background())
if name != "" {
t.Fatalf("provider name = %q, want empty on invalid policy", name)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed validation error", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want ValidationError", err, err)
}
if problem.Category != errs.CategoryValidation ||
problem.Subtype != errs.SubtypeInvalidArgument ||
validationErr.Param != envvars.CliStrictMode {
t.Fatalf("problem = %+v param = %q, want validation/invalid_argument param %s", problem, validationErr.Param, envvars.CliStrictMode)
}
}
func TestAuthSidecarTokenHonorsSelectedAppID(t *testing.T) {
t.Run("matching app returns sentinel", func(t *testing.T) {
cp := newRealSidecarCredentialProvider(t)
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT,
AppID: "cli_sidecar",
})
if err != nil {
t.Fatalf("ResolveToken: %v", err)
}
if result == nil || result.Token != sidecar.SentinelUAT {
t.Fatalf("result = %+v, want sidecar UAT sentinel", result)
}
})
for _, tt := range []struct {
name string
appID string
}{
{name: "empty app id", appID: ""},
{name: "conflicting app id", appID: "cli_other"},
} {
t.Run(tt.name, func(t *testing.T) {
cp := newRealSidecarCredentialProvider(t)
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT,
AppID: tt.appID,
})
if result != nil {
t.Fatalf("result = %+v, want no sidecar sentinel", result)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed internal error", err, err)
}
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeUnknown)
}
if strings.Contains(err.Error(), sidecar.SentinelUAT) {
t.Fatalf("error leaked sidecar sentinel: %v", err)
}
})
}
}

View File

@@ -9,17 +9,11 @@ import (
"fmt"
"io"
"net/http"
"os"
"slices"
"strings"
"sync"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
// DefaultAccountResolver is implemented by the default account provider.
@@ -142,21 +136,10 @@ type CredentialProvider struct {
httpClient func() (*http.Client, error)
warnOut io.Writer
// profile is the active profile (from --profile or LARKSUITE_CLI_PROFILE);
// profileSrc records which of the two supplied it, for the reported
// selection and error attribution.
profile string
profileSrc CredentialSourceKind
accountOnce sync.Once
account *Account
accountErr error
selectedSource credentialSource
// selection is the explainable credential-selection result, populated by
// doResolveAccount under accountOnce. It never carries a secret.
selection IdentitySelection
enrichOnce sync.Once
hintOnce sync.Once
hint *IdentityHint
@@ -178,521 +161,49 @@ func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
return p
}
// WithProfileFromFlag records the --profile flag value as the active profile.
// It governs credential arbitration and the reported selection source.
func (p *CredentialProvider) WithProfileFromFlag(profile string) *CredentialProvider {
p.profile = profile
p.profileSrc = SourceFlagProfile
return p
}
// WithProfileFromEnv records the LARKSUITE_CLI_PROFILE env fallback as the
// active profile. It governs credential arbitration and the reported
// selection source.
func (p *CredentialProvider) WithProfileFromEnv(profile string) *CredentialProvider {
p.profile = profile
p.profileSrc = SourceEnvProfile
return p
}
// ResolveAccount resolves app credentials. Result is cached after first call.
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
// Subsequent calls return the cached result regardless of their context.
// This is acceptable for CLI (single invocation per process) but not for long-running servers.
func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, error) {
acct, err := p.resolveAccountSelection(ctx)
if err != nil || acct == nil {
return acct, err
}
if _, ok := p.selectedSource.(extensionTokenSource); ok {
p.enrichOnce.Do(func() {
p.enrichOrClearIdentity(ctx, acct, p.selectedSource)
})
}
return acct, nil
}
// resolveAccountSelection performs and caches only credential selection. It
// deliberately does not resolve tokens or user_info, so callers can validate
// the selected app before any token work begins.
func (p *CredentialProvider) resolveAccountSelection(ctx context.Context) (*Account, error) {
p.accountOnce.Do(func() {
p.account, p.accountErr = p.doResolveAccount(ctx)
})
return p.account, p.accountErr
}
// doResolveAccount arbitrates the credential/App selection in three phases:
// gather all arbitration inputs in a single I/O pass, decide the route with a
// pure function, then execute the remaining I/O for the chosen route.
//
// Resolution order (encoded in decideIdentity): a managed extension provider
// (e.g. sidecar) wins outright; then an explicit profile (--profile /
// LARKSUITE_CLI_PROFILE) arbitrates against the direct env credential
// (matching app_id → profile supplies credential and tokens; mismatch → hard
// conflict; incomplete env without a usable app_id → repair error); then a
// complete direct env credential; then the config default (currentApp →
// firstApp).
//
// It populates p.selection (never carries a secret) and p.selectedSource on
// every success path.
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
in, err := p.gatherIdentityInputs(ctx)
if err != nil {
return nil, err
}
d, err := decideIdentity(in)
if err != nil {
return nil, err
}
acct, source, err := p.execute(ctx, d, in)
if err != nil {
return nil, err
}
p.selectedSource = source
// Assigned only after full success: error paths can never leave a
// partial selection behind.
p.selection = d.selection
return acct, nil
}
// providerAccount pairs an extension-provider account with its token source.
type providerAccount struct {
acct *Account
source extensionTokenSource
}
// identityInputs is one invocation's complete arbitration input, gathered in
// a single pass by gatherIdentityInputs. It is read-only after gathering;
// decideIdentity consumes it without further I/O.
type identityInputs struct {
profile string
profileSrc CredentialSourceKind
managed *providerAccount // managed extension account; wins arbitration outright
direct *providerAccount // complete direct env credential
// directBlock is a provider's explicit incomplete-direct-credential
// classification (BlockError.Code == credential_incomplete). It
// participates in profile arbitration instead of failing outright.
directBlock *extcred.BlockError
// directKeys / conflictKeys describe the BUILTIN process-env direct
// credential surface (LARKSUITE_CLI_* variable NAMES, never values).
// They annotate DirectCredentialEnv and conflict hints; a third-party
// AccountDirect provider reports its own inputs via BlockError metadata
// (PresentKeys/AppID), not through these.
directKeys []string
conflictKeys []string
config *core.MultiAppConfig
configErr error
}
// gatherIdentityInputs performs the arbitration's read phase: it consults the
// extension providers and snapshots the config. Providers classify their own
// failures at the source (BlockError.Code); this layer must not infer them by
// re-reading environment variables or parsing Reason.
func (p *CredentialProvider) gatherIdentityInputs(ctx context.Context) (identityInputs, error) {
in := identityInputs{
profile: p.profile,
profileSrc: p.profileSrc,
directKeys: presentDirectCredentialKeys(),
conflictKeys: presentDirectCredentialInputKeys(),
}
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)
if err != nil {
var blockErr *extcred.BlockError
if errors.As(err, &blockErr) {
switch blockErr.Code {
case extcred.BlockReasonCredentialIncomplete:
// app_credential_incomplete, profile matching, and
// DirectCredentialEnv diagnostics are defined in terms of
// the builtin LARKSUITE_CLI_* env surface. Until the SPI
// carries provider-owned input descriptors, accepting this
// classification from another provider would produce
// contradictory arbitration and repair hints.
if _, builtin := prov.(*envprovider.Provider); !builtin {
return in, newCredentialIncompleteProviderContractError(prov)
}
in.directBlock = blockErr
case extcred.BlockReasonInvalidPolicy:
// A user-supplied policy value failed validation; that is
// a validation error, never an internal one.
return in, newInvalidPolicyError(blockErr)
default:
// Blocks without a recognized Code preserve their
// original attribution.
return in, err
return nil, err
}
if acct != nil {
internal := convertAccount(acct)
source := extensionTokenSource{provider: prov}
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
}
break
// enrichUserInfo failure is non-fatal: SupportedIdentities
// (used for strict mode) is already set by the provider.
// Clear unverified user identity for safety.
internal.UserOpenId = ""
internal.UserName = ""
}
// Any other provider error preserves its original attribution.
return in, err
}
if acct == nil {
continue
}
pa := &providerAccount{acct: convertAccount(acct), source: extensionTokenSource{provider: prov}}
switch acct.Kind {
case extcred.AccountDirect:
// The arbitration's direct-credential surface — DirectCredentialEnv,
// the env:LARKSUITE_CLI_APP_ID selection source, conflict-hint
// keys — is defined in terms of the builtin process-env variables.
// Until the SPI carries provider-reported input descriptors, only
// the builtin env provider may declare AccountDirect; accepting it
// from anyone else would produce self-contradictory diagnostics
// (e.g. credentialSource "env:LARKSUITE_CLI_APP_ID" with
// directCredentialEnv.present=false). The check is by concrete
// type: the registry reserves neither names nor uniqueness, so a
// Name() comparison would be forgeable.
if _, builtin := prov.(*envprovider.Provider); !builtin {
return in, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider %q declared AccountDirect, which is reserved for the builtin env provider", prov.Name())
}
in.direct = pa
case extcred.AccountManaged:
in.managed = pa
default:
return in, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider %q returned unknown AccountKind %d", prov.Name(), acct.Kind)
}
break // the first engaged provider ends the scan (registry priority order)
}
// The config snapshot backs profile lookup, the config-default route, and
// config-default failure attribution. A winning managed or direct-env
// identity without a profile never needs it — and managed identities must
// keep working when the config is absent or malformed.
if in.managed == nil && (in.profile != "" || in.direct == nil) {
in.config, in.configErr = core.LoadOrNotConfigured()
}
return in, nil
}
// credentialRoute names which source serves the selected account and tokens.
type credentialRoute int
const (
routeManaged credentialRoute = iota
routeProfile
routeDirectEnv
routeConfigDefault
)
// decision is decideIdentity's complete verdict. Nothing in it touched I/O.
type decision struct {
route credentialRoute
selection IdentitySelection
// profileAppID is set on routeProfile; app_id is plaintext and safe to
// echo in the secret-invalid error.
profileAppID string
}
// decideIdentity holds every selection rule in one place: precedence
// (managed > profile > direct env > config default), profile/direct-env
// conflict detection, and error attribution. It is pure — same inputs, same
// verdict — so the full selection matrix is table-testable without env vars
// or config fixtures.
func decideIdentity(in identityInputs) (decision, error) {
// DirectCredentialEnv reports the direct env vars truthfully on every
// route: Present always means "direct credential env vars are set".
directEnv := DirectCredentialEnv{Present: len(in.directKeys) > 0, Keys: in.directKeys}
if in.direct != nil {
directEnv.AppID = in.direct.acct.AppID
}
switch {
case in.managed != nil:
return decision{route: routeManaged, selection: IdentitySelection{
Source: SourceExtension(in.managed.source.Name()),
DirectCredentialEnv: directEnv,
}}, nil
case in.profile != "":
return decideProfile(in, directEnv)
case in.directBlock != nil:
return decision{}, newAppCredentialIncompleteError(in.directBlock, false)
case in.direct != nil:
return decision{route: routeDirectEnv, selection: IdentitySelection{
Source: SourceEnvAppID,
DirectCredentialEnv: directEnv,
}}, nil
default:
return decision{route: routeConfigDefault, selection: IdentitySelection{
Source: selectionSourceForDefault(in.config),
DirectCredentialEnv: directEnv,
}}, nil
}
}
// decideProfile arbitrates an explicit profile against the direct env
// credential state.
func decideProfile(in identityInputs, directEnv DirectCredentialEnv) (decision, error) {
app, err := findProfile(in)
if err != nil {
return decision{}, err
}
if in.directBlock != nil {
// APP_ID-only is sufficient to compare sources: a matching selected
// profile supplies the credential and tokens; a mismatch is the same
// hard conflict as a complete direct env. Anything less than a usable
// app_id keeps the provider's repair error, extended with the
// unset-to-use-the-profile path.
if in.directBlock.AppID == "" || !slices.Contains(in.directBlock.PresentKeys, envvars.CliAppID) {
return decision{}, newAppCredentialIncompleteError(in.directBlock, true)
}
if app.AppId != in.directBlock.AppID {
return decision{}, newProfileAppCredentialConflict(
in.profile, app.AppId, in.directBlock.AppID, in.directBlock.PresentKeys)
}
directEnv.AppID = in.directBlock.AppID
directEnv.Matched = true
}
if in.direct != nil {
// E == complete: the direct env app_id must match the profile.
if app.AppId != in.direct.acct.AppID {
return decision{}, newProfileAppCredentialConflict(
in.profile, app.AppId, in.direct.acct.AppID, in.conflictKeys)
}
directEnv.Matched = true
}
return decision{
route: routeProfile,
selection: IdentitySelection{Source: in.profileSrc, DirectCredentialEnv: directEnv},
profileAppID: app.AppId,
}, nil
}
// findProfile resolves the requested profile against the config snapshot.
// A malformed config must surface its real typed cause (invalid_config):
// reporting it as profile_not_found would send the user to `profile list`
// and hide the broken file. Only a genuinely absent config degrades to
// profile_not_found, because the profile then cannot exist anywhere. Both
// deliberately outrank an incomplete direct env: fixing the profile side is
// what makes the selected profile usable.
func findProfile(in identityInputs) (*core.AppConfig, error) {
if in.configErr != nil {
if prob, ok := errs.ProblemOf(in.configErr); !ok || prob.Subtype != errs.SubtypeNotConfigured {
return nil, in.configErr
p.selectedSource = source
return internal, nil
}
}
if in.config != nil {
if app := in.config.FindApp(in.profile); app != nil {
return app, nil
}
}
return nil, errs.NewConfigError(errs.SubtypeProfileNotFound,
"profile %q not found", in.profile).
WithProfile(in.profile).
WithCredentialSource(string(in.profileSrc)).
WithHint("run `lark-cli profile list` to see available profiles.")
}
// execute performs the remaining I/O for the decided route and returns the
// account together with its token source.
func (p *CredentialProvider) execute(ctx context.Context, d decision, in identityInputs) (*Account, credentialSource, error) {
switch d.route {
case routeManaged:
return in.managed.acct, in.managed.source, nil
case routeDirectEnv:
return in.direct.acct, in.direct.source, nil
case routeProfile:
// Resolve the profile's own (keychain-backed) credential locally.
if p.defaultAcct != nil {
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
// A typed failure other than not_configured carries its own
// precise, secret-free diagnosis (typed errors never embed secret
// material per the error contract) — pass it through instead of
// flattening it into the generic secret error. Untyped failures
// and a config that vanished mid-resolution stay masked: their
// content is not guaranteed secret-free.
if prob, ok := errs.ProblemOf(err); ok && prob.Subtype != errs.SubtypeNotConfigured {
return nil, nil, err
}
return nil, nil, newProfileSecretInvalidError(in.profile, d.profileAppID)
return nil, err
}
// The resolver re-reads the config; a concurrent profile edit between
// gather and here could hand back a different app. Refuse the mismatch
// instead of silently using credentials the arbitration never checked.
if acct.AppID != d.profileAppID {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"config changed during resolution: profile %q resolved to a different app", in.profile).
WithHint("retry the command.")
}
return acct, defaultTokenSource{resolver: p.defaultToken}, nil
default: // routeConfigDefault
if p.defaultAcct == nil {
return nil, nil, core.NotConfiguredError()
}
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, nil, translateConfigDefaultFailure(err, in.config)
}
return acct, defaultTokenSource{resolver: p.defaultToken}, nil
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
return acct, nil
}
}
// translateConfigDefaultFailure attributes a config-default failure from the
// snapshot: a default profile that EXISTS (has an app_id) but whose secret
// cannot be resolved locally is profile_secret_invalid — "identity is
// configured, its secret is broken" is more actionable than "no active
// profile". Only when there is genuinely no usable default profile do we
// report no_active_profile. Other typed failures pass through unchanged.
func translateConfigDefaultFailure(err error, multi *core.MultiAppConfig) error {
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeNotConfigured {
return err
}
if multi != nil {
if app := multi.CurrentAppConfig(""); app != nil && app.AppId != "" {
return newProfileSecretInvalidError(app.ProfileName(), app.AppId)
}
}
return errs.NewConfigError(errs.SubtypeNoActiveProfile, "no active profile").
WithCredentialSource(noActiveProfileCredentialSource).
WithHint("run `lark-cli config init` / `lark-cli profile add`, or set %s.", envvars.CliProfile)
}
func newProfileAppCredentialConflict(profile, profileAppID, envAppID string, presentKeys []string) error {
err := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict,
"profile %q app_id does not match %s", profile, envvars.CliAppID).
WithProfileAppConflict(profileAppID, envAppID)
if len(presentKeys) > 0 {
return err.WithHint("unset %s, or select a profile whose app_id matches the environment.",
humanList(presentKeys, "and"))
}
return err.WithHint("unset the direct credential environment variables, or select a profile whose app_id matches the environment.")
}
func newAppCredentialIncompleteError(blockErr *extcred.BlockError, selectedProfileAvailable bool) *errs.ConfigError {
err := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "%s", blockErr.Reason).
WithCause(blockErr)
if len(blockErr.MissingKeys) > 0 {
err.WithMissingKeys(blockErr.MissingKeys...)
}
if len(blockErr.RequiredAnyOf) > 0 {
err.WithRequiredAnyOf(blockErr.RequiredAnyOf...)
}
hint := credentialRepairHint(blockErr)
if selectedProfileAvailable && len(blockErr.PresentKeys) > 0 {
hint += fmt.Sprintf(", or unset %s to use the selected profile", humanList(blockErr.PresentKeys, "and"))
}
return err.WithHint("%s.", hint)
}
func credentialRepairHint(blockErr *extcred.BlockError) string {
if len(blockErr.RequiredAnyOf) > 0 {
return "set " + humanList(blockErr.RequiredAnyOf, "or")
}
return "set " + humanList(blockErr.MissingKeys, "and")
}
func humanList(items []string, conjunction string) string {
switch len(items) {
case 0:
return "the missing direct credential variables"
case 1:
return items[0]
case 2:
return items[0] + " " + conjunction + " " + items[1]
default:
return strings.Join(items[:len(items)-1], ", ") + ", " + conjunction + " " + items[len(items)-1]
}
}
// newInvalidPolicyError translates a provider's invalid-policy block into the
// typed validation contract: the failed variable name travels in param, the
// repair path in the hint, and the original block stays on the cause chain.
// Reason carries only the variable name and its non-secret value.
func newInvalidPolicyError(blockErr *extcred.BlockError) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", blockErr.Reason).
WithParam(blockErr.Param).
WithCause(blockErr).
WithHint("set %s to a supported value or unset it.", blockErr.Param)
}
func newCredentialIncompleteProviderContractError(prov extcred.Provider) error {
return errs.NewInternalError(errs.SubtypeUnknown,
"credential provider %q returned credential_incomplete, which is reserved for the builtin env provider", prov.Name())
}
// newProfileSecretInvalidError is deliberately generic (SECURITY): the
// underlying cause may carry secret material, so neither it nor its message
// may reach the envelope. app_id is plaintext and safe to echo.
func newProfileSecretInvalidError(profile, appID string) error {
return errs.NewConfigError(errs.SubtypeProfileSecretInvalid,
"profile %q credential could not be resolved locally", profile).
WithProfile(profile).
WithAppID(appID).
WithHint("verify the profile's app secret or re-add the profile with `lark-cli config`.")
}
// enrichOrClearIdentity verifies a provider-supplied user identity via
// enrichUserInfo. Verification failure is non-fatal — SupportedIdentities
// (used for strict mode) is already set by the provider — but an unverified
// identity must not survive it: a stale OpenID would attribute calls to a
// user the token can no longer act for.
func (p *CredentialProvider) enrichOrClearIdentity(ctx context.Context, acct *Account, source credentialSource) {
err := p.enrichUserInfo(ctx, acct, source)
if err == nil {
return
}
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
}
acct.UserOpenId = ""
acct.UserName = ""
}
// noActiveProfileCredentialSource is the credential_source reported on the
// no_active_profile error. The error contract fixes this to the literal "config": there is
// no resolved default profile at all, so the more specific config:currentApp /
// config:firstApp source values (used on successful config-default selections)
// would be misleading. It is an enum string, never a secret.
const noActiveProfileCredentialSource = "config"
// selectionSourceForDefault reports whether the config default resolved to the
// explicit currentApp or fell back to the first app.
func selectionSourceForDefault(multi *core.MultiAppConfig) CredentialSourceKind {
if multi != nil && multi.CurrentApp != "" {
return SourceConfigCurrentApp
}
return SourceConfigFirstApp
}
// presentDirectCredentialKeys returns the NAMES (never values) of the direct
// app credential env vars that are set. Used to annotate DirectCredentialEnv.
func presentDirectCredentialKeys() []string {
var keys []string
if os.Getenv(envvars.CliAppID) != "" {
keys = append(keys, envvars.CliAppID)
}
if os.Getenv(envvars.CliAppSecret) != "" {
keys = append(keys, envvars.CliAppSecret)
}
return keys
}
// presentDirectCredentialInputKeys returns all direct env input names that
// must be cleared together to remove a profile/app_id conflict. Values are
// never returned.
func presentDirectCredentialInputKeys() []string {
keys := presentDirectCredentialKeys()
if os.Getenv(envvars.CliUserAccessToken) != "" {
keys = append(keys, envvars.CliUserAccessToken)
}
if os.Getenv(envvars.CliTenantAccessToken) != "" {
keys = append(keys, envvars.CliTenantAccessToken)
}
return keys
}
// Selection resolves the account (once) and returns the cached, secret-free
// explanation of how the credential/App was selected. It mirrors
// selectedCredentialSource: resolve-then-return.
func (p *CredentialProvider) Selection(ctx context.Context) (IdentitySelection, error) {
if _, err := p.ResolveAccount(ctx); err != nil {
return IdentitySelection{}, err
}
return p.selection, nil
return nil, core.NotConfiguredError()
}
// enrichUserInfo resolves user identity when extension provides a UAT.
@@ -728,13 +239,17 @@ func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account,
}
func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) {
if _, err := p.resolveAccountSelection(ctx); err != nil {
if p.selectedSource != nil {
return p.selectedSource, nil
}
if p.defaultAcct == nil {
return nil, nil
}
if _, err := p.ResolveAccount(ctx); err != nil {
return nil, err
}
if p.selectedSource == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider resolved an account without selecting a token source").
WithHint("retry the command.")
return nil, fmt.Errorf("credential provider resolved an account without selecting a token source")
}
return p.selectedSource, nil
}
@@ -787,88 +302,51 @@ func (p *CredentialProvider) doResolveIdentityHint(ctx context.Context) (*Identi
// ResolveToken resolves an access token.
func (p *CredentialProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
acct, err := p.resolveAccountSelection(ctx)
source, err := p.selectedCredentialSource(ctx)
if err != nil {
return nil, err
}
if acct == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider resolved no account before %s token resolution", req.Type).
WithHint("retry the command.")
if source != nil {
return resolveTokenFromSource(ctx, source, req)
}
source := p.selectedSource
if source == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider resolved app %q without selecting a token source", acct.AppID).
WithHint("retry the command.")
for _, prov := range p.providers {
source := extensionTokenSource{provider: prov}
result, found, err := source.TryResolveToken(ctx, req)
if err != nil {
return nil, err
}
if found {
return result, nil
}
}
if req.AppID == "" {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"TokenSpec.AppID is required for %s token resolution", req.Type).
WithHint("retry the command.")
source = defaultTokenSource{resolver: p.defaultToken}
result, found, err := source.TryResolveToken(ctx, req)
if err != nil {
return nil, err
}
if req.AppID != acct.AppID {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"token requested for app %q but the selected account belongs to app %q", req.AppID, acct.AppID).
WithHint("retry the command.")
if found {
return result, nil
}
return resolveTokenFromSource(ctx, source, req)
return nil, &TokenUnavailableError{Type: req.Type}
}
// ActiveExtensionProviderName reports whether an extension provider is managing
// the credentials that actually win selection. With an explicit profile that
// resolves successfully it reuses ResolveAccount's cached arbitration result;
// otherwise it probes extension providers directly and returns the first
// engaged provider.
// credentials. It probes p.providers (extension providers only, not defaultAcct)
// and returns the name of the first engaged provider.
//
// "Engaged" means: ResolveAccount returns a non-nil account, OR returns a
// *extcred.BlockError (provider configured but misconfigured — still counts as
// external). Any other probe error is propagated to the caller.
//
// A failed profile resolution (profile not found, broken secret, malformed
// config, incomplete direct env, ...) deliberately does NOT propagate: this
// probe guards the builtin setup/repair commands (auth, config), and an
// unresolvable credential must never lock the user out of the commands that
// fix it. It falls back to the engagement probe, which answers the only
// question this function owns: is an extension provider holding credentials?
// external). Any other error is propagated to the caller.
//
// Returns ("", nil) when no extension provider is active (built-in keychain path).
// Safe to call multiple times: explicit-profile resolution uses sync.Once, while
// the probe path only consults providers.
// Safe to call multiple times — probes providers directly without the sync.Once cache.
func (p *CredentialProvider) ActiveExtensionProviderName(ctx context.Context) (string, error) {
// With an explicit profile, report the source that actually won the same
// arbitration used by commands. A matching APP_ID-only env block is not an
// external takeover once the selected profile supplies credentials/tokens.
if p.profile != "" {
if _, err := p.ResolveAccount(ctx); err == nil {
if p.selectedSource == nil {
return "", nil
}
if _, builtin := p.selectedSource.(defaultTokenSource); builtin {
return "", nil
}
return p.selectedSource.Name(), nil
}
// Resolution failed — fall through to the engagement probe.
}
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)
if err != nil {
var blockErr *extcred.BlockError
if errors.As(err, &blockErr) {
// Align with formal arbitration: a misconfigured policy
// variable is the same typed validation error everywhere —
// not an external takeover of the provider that reported it,
// and not license to keep scanning and blame a later
// provider instead.
if blockErr.Code == extcred.BlockReasonInvalidPolicy {
return "", newInvalidPolicyError(blockErr)
}
if blockErr.Code == extcred.BlockReasonCredentialIncomplete {
if _, builtin := prov.(*envprovider.Provider); !builtin {
return "", newCredentialIncompleteProviderContractError(prov)
}
}
name := blockErr.Provider
if name == "" {
name = prov.Name()

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
@@ -24,7 +23,6 @@ type mockExtProvider struct {
err error
accountErr error
tokenErr error
tokenCalls int
}
func (m *mockExtProvider) Name() string { return m.name }
@@ -35,7 +33,6 @@ func (m *mockExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account,
return m.account, m.err
}
func (m *mockExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
m.tokenCalls++
if m.tokenErr != nil {
return nil, m.tokenErr
}
@@ -52,13 +49,11 @@ func (m *mockDefaultAcct) ResolveAccount(ctx context.Context) (*Account, error)
}
type mockDefaultToken struct {
result *TokenResult
err error
tokenCalls int
result *TokenResult
err error
}
func (m *mockDefaultToken) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
m.tokenCalls++
return m.result, m.err
}
@@ -121,45 +116,35 @@ func TestCredentialProvider_AccountCached(t *testing.T) {
}
func TestCredentialProvider_TokenFromExtension(t *testing.T) {
for _, sourceName := range []string{"env", "authsidecar"} {
t.Run(sourceName, func(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: sourceName,
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: sourceName},
}},
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
if err != nil {
t.Fatal(err)
}
if result.Token != "ext_tok" {
t.Errorf("expected ext_tok, got %s", result.Token)
}
})
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: "env",
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: "env"},
}},
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err != nil {
t.Fatal(err)
}
if result.Token != "ext_tok" {
t.Errorf("expected ext_tok, got %s", result.Token)
}
}
func TestCredentialProvider_TokenFallsToDefault(t *testing.T) {
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "skip"}},
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
defaultToken, nil,
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err != nil {
t.Fatal(err)
}
if result.Token != "default_tok" {
t.Errorf("expected default_tok, got %s", result.Token)
}
if defaultToken.tokenCalls != 1 {
t.Fatalf("default ResolveToken() calls = %d, want 1", defaultToken.tokenCalls)
}
}
func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t *testing.T) {
@@ -174,7 +159,7 @@ func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t
t.Fatalf("ResolveAccount() error = %v", err)
}
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err != nil {
t.Fatalf("ResolveToken() error = %v", err)
}
@@ -196,7 +181,7 @@ func TestCredentialProvider_SelectedSourceWithoutTokenReturnsUnavailableError(t
t.Fatalf("ResolveAccount() error = %v", err)
}
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err == nil {
t.Fatal("ResolveToken() error = nil, want unavailable error")
}
@@ -217,7 +202,7 @@ func TestCredentialProvider_ResolveTokenPropagatesNonBlockExtensionError(t *test
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err == nil || err.Error() != "provider exploded" {
t.Fatalf("ResolveToken() error = %v, want provider exploded", err)
}
@@ -327,12 +312,12 @@ func TestCredentialProvider_ResolveIdentityHint_CachesResult(t *testing.T) {
func TestCredentialProvider_ResolveTokenTreatsEmptyDefaultTokenAsMalformed(t *testing.T) {
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
nil,
&mockDefaultToken{result: &TokenResult{Token: ""}},
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err == nil || !strings.Contains(err.Error(), "empty token") {
t.Fatalf("ResolveToken() error = %v, want malformed empty token error", err)
}
@@ -425,189 +410,17 @@ func TestCredentialProvider_ResolveAccountWarnsWhenExtensionIdentityVerification
}
func TestCredentialProvider_ResolveTokenDoesNotBypassFailedDefaultAccountResolution(t *testing.T) {
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{err: errors.New("config unavailable")},
defaultToken,
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err == nil || err.Error() != "config unavailable" {
t.Fatalf("ResolveToken() error = %v, want config unavailable", err)
}
if defaultToken.tokenCalls != 0 {
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
}
}
func TestCredentialProvider_ResolveTokenRejectsUnboundAppBeforeExtensionIO(t *testing.T) {
tests := []struct {
name string
appID string
}{
{name: "empty app id"},
{name: "different app id", appID: "other_app"},
}
for _, tt := range tests {
for _, sourceName := range []string{"env", "authsidecar"} {
t.Run(tt.name+"/"+sourceName, func(t *testing.T) {
provider := &mockExtProvider{
name: sourceName,
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: sourceName},
}
httpClientCalls := 0
cp := NewCredentialProvider(
[]extcred.Provider{provider},
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
func() (*http.Client, error) {
httpClientCalls++
return nil, errors.New("unexpected user_info call")
},
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: tt.appID})
if err == nil {
t.Fatal("ResolveToken() error = nil, want app binding error")
}
assertInternalUnknownWithRetryHint(t, err)
if provider.tokenCalls != 0 {
t.Fatalf("extension ResolveToken() calls = %d, want 0", provider.tokenCalls)
}
if httpClientCalls != 0 {
t.Fatalf("httpClient() calls = %d, want 0", httpClientCalls)
}
})
}
}
}
func TestCredentialProvider_ResolveTokenRejectsUnboundAppBeforeDefaultIO(t *testing.T) {
tests := []struct {
name string
appID string
}{
{name: "empty app id"},
{name: "different app id", appID: "other_app"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
defaultToken,
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: tt.appID})
if err == nil {
t.Fatal("ResolveToken() error = nil, want app binding error")
}
assertInternalUnknownWithRetryHint(t, err)
if defaultToken.tokenCalls != 0 {
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
}
})
}
}
func TestCredentialProvider_ResolveTokenRejectsNilAccountBeforeTokenIO(t *testing.T) {
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{},
defaultToken,
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "requested_app"})
if err == nil {
t.Fatal("ResolveToken() error = nil, want nil account error")
}
assertInternalUnknownWithRetryHint(t, err)
if defaultToken.tokenCalls != 0 {
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
}
}
func TestCredentialProvider_ResolveTokenRejectsMissingSelectedSourceWithoutFallback(t *testing.T) {
extension := &mockExtProvider{
name: "env",
token: &extcred.Token{Value: "ext_tok", Source: "env"},
}
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
[]extcred.Provider{extension},
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
defaultToken,
nil,
)
cp.account = &Account{AppID: "selected_app"}
cp.accountOnce.Do(func() {})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "selected_app"})
if err == nil {
t.Fatal("ResolveToken() error = nil, want missing selected source error")
}
assertInternalUnknownWithRetryHint(t, err)
if extension.tokenCalls != 0 {
t.Fatalf("extension ResolveToken() calls = %d, want 0", extension.tokenCalls)
}
if defaultToken.tokenCalls != 0 {
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
}
}
func TestCredentialProvider_ResolveTokenMatchingExtensionDoesNotEnrichIdentity(t *testing.T) {
provider := &mockExtProvider{
name: "env",
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: "env"},
}
httpClientCalls := 0
cp := NewCredentialProvider(
[]extcred.Provider{provider},
nil,
nil,
func() (*http.Client, error) {
httpClientCalls++
return nil, errors.New("unexpected user_info call")
},
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
if err != nil {
t.Fatalf("ResolveToken() error = %v", err)
}
if result.Token != "ext_tok" {
t.Fatalf("ResolveToken() token = %q, want %q", result.Token, "ext_tok")
}
if provider.tokenCalls != 1 {
t.Fatalf("extension ResolveToken() calls = %d, want 1", provider.tokenCalls)
}
if httpClientCalls != 0 {
t.Fatalf("httpClient() calls = %d, want 0", httpClientCalls)
}
}
func assertInternalUnknownWithRetryHint(t *testing.T, err error) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed internal error", err)
}
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("error problem = %+v, want internal/unknown", problem)
}
if problem.Hint != "retry the command." {
t.Fatalf("error hint = %q, want retry hint", problem.Hint)
}
}
func TestActiveExtensionProviderName_ExtActive(t *testing.T) {

View File

@@ -1,181 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
// stubDecideProvider satisfies extcred.Provider for building providerAccount
// literals; decideIdentity only ever calls Name() on it.
type stubDecideProvider struct{ name string }
func (s stubDecideProvider) Name() string { return s.name }
func (s stubDecideProvider) Priority() int { return 0 }
func (s stubDecideProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return nil, nil
}
func (s stubDecideProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
func pa(providerName, appID string) *providerAccount {
return &providerAccount{
acct: &Account{AppID: appID},
source: extensionTokenSource{provider: stubDecideProvider{name: providerName}},
}
}
func appIDOnlyBlock(appID string) *extcred.BlockError {
return &extcred.BlockError{
Provider: "env",
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
Code: extcred.BlockReasonCredentialIncomplete,
RequiredAnyOf: []string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken},
PresentKeys: []string{envvars.CliAppID},
AppID: appID,
}
}
func uatOnlyBlock() *extcred.BlockError {
return &extcred.BlockError{
Provider: "env",
Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing",
Code: extcred.BlockReasonCredentialIncomplete,
MissingKeys: []string{envvars.CliAppID},
PresentKeys: []string{envvars.CliUserAccessToken},
}
}
// TestDecideIdentity exercises the selection matrix as data: decideIdentity is
// pure, so every rule (precedence, conflict detection, error attribution) is
// table-testable without env vars or config fixtures.
func TestDecideIdentity(t *testing.T) {
tenantA := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{Name: "tenant_a", AppId: "cli_a"}},
}
noCurrent := &core.MultiAppConfig{
Apps: []core.AppConfig{{Name: "tenant_a", AppId: "cli_a"}},
}
invalidConfigErr := errs.NewConfigError(errs.SubtypeInvalidConfig, "invalid config format")
notConfiguredErr := core.NotConfiguredError()
cases := []struct {
name string
in identityInputs
route credentialRoute
source CredentialSourceKind
matched bool
subtype errs.Subtype // "" = success expected
}{
{
name: "managed provider wins over explicit profile",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, managed: pa("sidecar", "sidecar_app"), config: tenantA},
route: routeManaged,
source: SourceExtension("sidecar"),
},
{
name: "profile conflicts with complete direct env app_id",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, direct: pa("env", "cli_x"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}, config: tenantA},
subtype: errs.SubtypeProfileAppCredentialConflict,
},
{
name: "matched complete direct env yields profile route",
in: identityInputs{profile: "tenant_a", profileSrc: SourceEnvProfile, direct: pa("env", "cli_a"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}, config: tenantA},
route: routeProfile,
source: SourceEnvProfile,
matched: true,
},
{
name: "APP_ID-only block matching the profile yields profile route",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}, config: tenantA},
route: routeProfile,
source: SourceFlagProfile,
matched: true,
},
{
name: "APP_ID-only block mismatching the profile is a hard conflict",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: appIDOnlyBlock("cli_x"), directKeys: []string{envvars.CliAppID}, config: tenantA},
subtype: errs.SubtypeProfileAppCredentialConflict,
},
{
name: "UAT-only block with a valid profile keeps the repair error",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: uatOnlyBlock(), config: tenantA},
subtype: errs.SubtypeAppCredentialIncomplete,
},
{
name: "block without profile is app_credential_incomplete",
in: identityInputs{directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}},
subtype: errs.SubtypeAppCredentialIncomplete,
},
{
name: "complete direct env without profile wins",
in: identityInputs{direct: pa("env", "cli_env"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}},
route: routeDirectEnv,
source: SourceEnvAppID,
},
{
name: "malformed config is not masked as profile_not_found",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, configErr: invalidConfigErr},
subtype: errs.SubtypeInvalidConfig,
},
{
name: "absent config degrades to profile_not_found",
in: identityInputs{profile: "ghost", profileSrc: SourceEnvProfile, configErr: notConfiguredErr},
subtype: errs.SubtypeProfileNotFound,
},
{
name: "profile missing from a valid config is profile_not_found even with incomplete env",
in: identityInputs{profile: "ghost", profileSrc: SourceEnvProfile, directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}, config: tenantA},
subtype: errs.SubtypeProfileNotFound,
},
{
name: "config default reports currentApp",
in: identityInputs{config: tenantA},
route: routeConfigDefault,
source: SourceConfigCurrentApp,
},
{
name: "config default without currentApp reports firstApp",
in: identityInputs{config: noCurrent},
route: routeConfigDefault,
source: SourceConfigFirstApp,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
d, err := decideIdentity(tc.in)
if tc.subtype != "" {
if err == nil {
t.Fatalf("decideIdentity = %+v, want error subtype %q", d, tc.subtype)
}
prob, ok := errs.ProblemOf(err)
if !ok || prob.Subtype != tc.subtype {
t.Fatalf("error = %v, want subtype %q", err, tc.subtype)
}
return
}
if err != nil {
t.Fatalf("decideIdentity: %v", err)
}
if d.route != tc.route {
t.Errorf("route = %d, want %d", d.route, tc.route)
}
if d.selection.Source != tc.source {
t.Errorf("source = %q, want %q", d.selection.Source, tc.source)
}
if d.selection.DirectCredentialEnv.Matched != tc.matched {
t.Errorf("matched = %v, want %v", d.selection.DirectCredentialEnv.Matched, tc.matched)
}
})
}
}

View File

@@ -74,13 +74,9 @@ func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string
func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) {
// Load config once — used for both credentials and strict mode.
// LoadOrNotConfigured distinguishes an absent config (→ not_configured)
// from a malformed/unreadable one (→ invalid_config with cause), so a
// broken config is never masked as "run config init" — matching the
// explicit-profile path in doResolveAccount.
multi, err := core.LoadOrNotConfigured()
multi, err := core.LoadMultiAppConfig()
if err != nil {
return nil, err
return nil, core.NotConfiguredError()
}
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
@@ -120,7 +116,6 @@ type DefaultTokenProvider struct {
tatOnce sync.Once
tatResult *TokenResult
tatAppID string
tatErr error
}
@@ -131,42 +126,21 @@ func NewDefaultTokenProvider(defaultAcct *DefaultAccountProvider, httpClient fun
func (p *DefaultTokenProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
switch req.Type {
case TokenTypeUAT:
return p.resolveUAT(ctx, req)
return p.resolveUAT(ctx)
case TokenTypeTAT:
return p.resolveTAT(ctx, req)
return p.resolveTAT(ctx)
default:
return nil, fmt.Errorf("unsupported token type: %s", req.Type)
}
}
// checkTokenAppID refuses to hand out a token for a different app than the
// caller resolved. The token provider re-reads the config, so a concurrent
// profile edit between account resolution and token resolution could otherwise
// cross tokens between apps. TokenSpec.AppID is REQUIRED here: an empty value
// would silently disable the guarantee, so it is rejected rather than skipped.
func checkTokenAppID(req TokenSpec, resolvedAppID string) error {
if req.AppID == "" {
return errs.NewInternalError(errs.SubtypeUnknown,
"TokenSpec.AppID is required for %s token resolution", req.Type)
}
if req.AppID == resolvedAppID {
return nil
}
return errs.NewInternalError(errs.SubtypeUnknown,
"config changed during resolution: token requested for app %q but the saved profile now resolves to a different app", req.AppID).
WithHint("retry the command.")
}
// resolveUAT resolves a user access token. Not cached (unlike TAT) because UAT
// may be refreshed between calls and GetValidAccessToken handles its own caching.
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context, req TokenSpec) (*TokenResult, error) {
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, error) {
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, err
}
if err := checkTokenAppID(req, acct.AppID); err != nil {
return nil, err
}
httpClient, err := p.httpClient()
if err != nil {
return nil, err
@@ -183,36 +157,20 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context, req TokenSpec) (*
return &TokenResult{Token: token, Scopes: scopes}, nil
}
// resolveTAT resolves a tenant access token. The result is cached after the
// first mint via sync.Once — only the context from that call is used.
//
// The account is resolved and checked against the request BEFORE any token
// work: a mismatched request must not trigger a token mint (network call,
// quota, audit trail) for the wrong app. The cached result is additionally
// re-checked on every hit, so a token minted for one app is never served to
// a request that resolved another.
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context, req TokenSpec) (*TokenResult, error) {
// resolveTAT resolves a tenant access token. The result is cached after the first
// call via sync.Once — only the context from the first call is used.
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
p.tatOnce.Do(func() {
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
})
return p.tatResult, p.tatErr
}
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, error) {
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, err
}
if err := checkTokenAppID(req, acct.AppID); err != nil {
return nil, err
}
p.tatOnce.Do(func() {
p.tatResult, p.tatErr = p.doResolveTAT(ctx, acct)
p.tatAppID = acct.AppID
})
if p.tatErr != nil {
return nil, p.tatErr
}
if err := checkTokenAppID(req, p.tatAppID); err != nil {
return nil, err
}
return p.tatResult, nil
}
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context, acct *Account) (*TokenResult, error) {
httpClient, err := p.httpClient()
if err != nil {
return nil, err

View File

@@ -4,15 +4,10 @@
package credential
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
func TestDefaultTokenProvider_Dispatches(t *testing.T) {
@@ -97,136 +92,3 @@ func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
}
}
func TestCheckTokenAppID(t *testing.T) {
if err := checkTokenAppID(TokenSpec{Type: TokenTypeUAT}, "cli_a"); err == nil {
t.Fatal("empty requested app must be rejected: it would silently disable the guarantee")
}
if err := checkTokenAppID(TokenSpec{AppID: "cli_a"}, "cli_a"); err != nil {
t.Fatalf("matching app must pass: %v", err)
}
err := checkTokenAppID(TokenSpec{AppID: "cli_a"}, "cli_b")
if err == nil {
t.Fatal("mismatched app must be refused")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error type = %T, want *errs.InternalError", err)
}
}
// REAL-path regression for review F2: the token provider re-reads the config,
// so a profile edit between account resolution and token resolution must not
// hand a token minted for the new app to a caller that resolved the old one.
// Uses the real DefaultAccountProvider + DefaultTokenProvider; the HTTP stub
// makes the network step unreachable, so reaching it proves the app check ran
// and passed first.
func TestDefaultTokenProvider_RefusesTokenAfterConfigSwap(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeCfg := func(appID string) {
t.Helper()
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
Name: "tenant_a", AppId: appID, AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
}
writeCfg("cli_a")
httpSentinel := errors.New("http client sentinel: unreachable in test")
tp := NewDefaultTokenProvider(
NewDefaultAccountProvider(nil, "tenant_a"),
func() (*http.Client, error) { return nil, httpSentinel },
nil,
)
// Matching app: the consistency check passes and resolution proceeds to
// the (stubbed) HTTP step.
_, err := tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "cli_a"})
if !errors.Is(err, httpSentinel) {
t.Fatalf("err = %v, want the HTTP sentinel (check must pass for a matching app)", err)
}
// The profile now resolves to a different app: the token request that was
// arbitrated for cli_a must be refused before any token work happens.
writeCfg("cli_b")
_, err = tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "cli_a"})
if err == nil || !strings.Contains(err.Error(), "config changed during resolution") {
t.Fatalf("err = %v, want config-changed refusal", err)
}
}
// F1 regression: a TAT request for a mismatched app must be refused BEFORE
// any token work starts — no HTTP client construction, no mint, no cache —
// otherwise the CLI mints (and caches) a token for the wrong app and only
// then refuses to return it, leaving auth audit/quota side effects behind.
func TestDefaultTokenProvider_TATChecksAppBeforeAnyTokenWork(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
Name: "tenant_a", AppId: "cli_b", AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
httpCalled := false
tp := NewDefaultTokenProvider(
NewDefaultAccountProvider(nil, "tenant_a"),
func() (*http.Client, error) { httpCalled = true; return nil, errors.New("http sentinel") },
nil,
)
// The profile resolves to cli_b, but the caller arbitrated cli_a.
_, err := tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeTAT, AppID: "cli_a"})
if err == nil || !strings.Contains(err.Error(), "config changed during resolution") {
t.Fatalf("err = %v, want config-changed refusal", err)
}
if httpCalled {
t.Fatal("token work started for a mismatched app: the check must run before any HTTP client is built")
}
}
// countingTATTripper serves a canned successful TAT response and counts calls.
type countingTATTripper struct{ calls int }
func (c *countingTATTripper) RoundTrip(*http.Request) (*http.Response, error) {
c.calls++
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"code":0,"access_token":"your-access-token"}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}
// TAT happy path: the first request mints the token over HTTP, the second is
// served from the sync.Once cache without another HTTP call.
func TestDefaultTokenProvider_TATSuccessAndCacheHit(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
tripper := &countingTATTripper{}
tp := NewDefaultTokenProvider(
NewDefaultAccountProvider(nil, "tenant_a"),
func() (*http.Client, error) { return &http.Client{Transport: tripper}, nil },
nil,
)
req := TokenSpec{Type: TokenTypeTAT, AppID: "cli_a"}
first, err := tp.ResolveToken(context.Background(), req)
if err != nil || first.Token != "your-access-token" {
t.Fatalf("first resolve = %+v, %v; want minted token", first, err)
}
second, err := tp.ResolveToken(context.Background(), req)
if err != nil || second.Token != "your-access-token" {
t.Fatalf("second resolve = %+v, %v; want cached token", second, err)
}
if tripper.calls != 1 {
t.Fatalf("HTTP calls = %d, want exactly 1 (second resolve must hit the cache)", tripper.calls)
}
}

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
// CredentialSourceKind is the wire-stable App/credential selection source.
type CredentialSourceKind string
const (
SourceFlagProfile CredentialSourceKind = "flag:--profile"
SourceEnvProfile CredentialSourceKind = "env:LARKSUITE_CLI_PROFILE"
SourceEnvAppID CredentialSourceKind = "env:LARKSUITE_CLI_APP_ID"
SourceConfigCurrentApp CredentialSourceKind = "config:currentApp"
SourceConfigFirstApp CredentialSourceKind = "config:firstApp"
// SourceExtensionPrefix prefixes the name of a managed extension provider
// that won selection outright (e.g. "extension:sidecar"). With it, an
// empty Source is left with exactly one meaning: not resolved.
SourceExtensionPrefix CredentialSourceKind = "extension:"
)
// SourceExtension reports the selection source for a managed extension
// provider by name.
func SourceExtension(name string) CredentialSourceKind {
return SourceExtensionPrefix + CredentialSourceKind(name)
}
// DirectCredentialEnv describes the state of direct app credential env vars.
// It never carries a secret value — only names and the non-sensitive app_id.
type DirectCredentialEnv struct {
Present bool `json:"present"`
Keys []string `json:"keys,omitempty"`
AppID string `json:"appId,omitempty"`
Matched bool `json:"matched,omitempty"`
ConflictsWithProfile bool `json:"conflictsWithProfile,omitempty"`
}
// IdentitySelection is the explainable result of credential selection.
// It carries NO secret value.
type IdentitySelection struct {
Source CredentialSourceKind
DirectCredentialEnv DirectCredentialEnv
}
// Explicit reports whether the identity was actively specified by the
// user/agent (flag or env), which governs no-fallback behavior.
func (s IdentitySelection) Explicit() bool {
switch s.Source {
case SourceFlagProfile, SourceEnvProfile, SourceEnvAppID:
return true
default:
return false
}
}

View File

@@ -1,25 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import "testing"
func TestIdentitySelectionExplicit(t *testing.T) {
cases := []struct {
src CredentialSourceKind
explicit bool
}{
{SourceFlagProfile, true},
{SourceEnvProfile, true},
{SourceEnvAppID, true},
{SourceConfigCurrentApp, false},
{SourceConfigFirstApp, false},
}
for _, c := range cases {
sel := IdentitySelection{Source: c.src}
if sel.Explicit() != c.explicit {
t.Errorf("source %q: Explicit()=%v want %v", c.src, sel.Explicit(), c.explicit)
}
}
}

View File

@@ -52,24 +52,6 @@ func TestFullChain_EnvWins(t *testing.T) {
}
}
func TestFullChain_EnvRejectsDifferentApp(t *testing.T) {
t.Setenv(envvars.CliAppID, "env_app")
t.Setenv(envvars.CliAppSecret, "env_secret")
t.Setenv(envvars.CliUserAccessToken, "env_uat")
cp := credential.NewCredentialProvider(
[]extcred.Provider{&envprovider.Provider{}},
nil, nil, nil,
)
_, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT, AppID: "other_app",
})
if err == nil {
t.Fatal("ResolveToken() error = nil, want app binding error")
}
}
func TestFullChain_Fallthrough(t *testing.T) {
// env provider returns nil (no env vars set), falls through to default token
ep := &envprovider.Provider{}
@@ -77,8 +59,7 @@ func TestFullChain_Fallthrough(t *testing.T) {
cp := credential.NewCredentialProvider(
[]extcred.Provider{ep},
&mockDefaultAccountProvider{account: &credential.Account{AppID: "app1"}},
mock, nil,
nil, mock, nil,
)
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT, AppID: "app1",
@@ -91,14 +72,6 @@ func TestFullChain_Fallthrough(t *testing.T) {
}
}
type mockDefaultAccountProvider struct {
account *credential.Account
}
func (m *mockDefaultAccountProvider) ResolveAccount(context.Context) (*credential.Account, error) {
return m.account, nil
}
type mockDefaultTokenProvider struct {
token string
scopes string

View File

@@ -21,7 +21,6 @@ const (
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliProfile = "LARKSUITE_CLI_PROFILE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.77",
"version": "1.0.79",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.77",
"version": "1.0.79",
"cpu": [
"x64",
"arm64",

View File

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

View File

@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
exit 1
fi
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
if grep -Fq 'run.name !== "CI"' "$workflow"; then
echo "semantic-review must not use the dynamic workflow run name as workflow identity" >&2
exit 1
fi
require_in_step "$summary_verify_step" 'github.rest.actions.getWorkflow' "PR quality summary must resolve static workflow metadata"
require_in_step "$summary_verify_step" 'workflow.name !== "CI"' "PR quality summary must verify the static workflow name"
require_in_step "$summary_verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "PR quality summary must verify the static workflow path"
require_in_step "$summary_verify_step" 'run.path && run.path !== workflow.path' "PR quality summary must reject workflow path metadata mismatches"
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
@@ -201,7 +209,10 @@ require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR qualit
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
require_in_step "$verify_step" 'github.rest.actions.getWorkflow' "semantic-review must resolve static workflow metadata"
require_in_step "$verify_step" 'workflow.name !== "CI"' "semantic-review must verify the static workflow name"
require_in_step "$verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "semantic-review must verify the static workflow path"
require_in_step "$verify_step" 'run.path && run.path !== workflow.path' "semantic-review must reject workflow path metadata mismatches"
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"

View File

@@ -250,7 +250,7 @@ var CalendarAgenda = common.Shortcut{
}
}
backfillDescriptionRich(e)
collapseDescription(e)
filtered = append(filtered, e)
}

View File

@@ -32,8 +32,8 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
if rrule := runtime.Str("rrule"); rrule != "" {
eventData["recurrence"] = rrule
}
if descriptionRich := descriptionRichToSend(runtime); descriptionRich != "" {
eventData["description_rich"] = descriptionRich
if description := descriptionToSend(runtime); description != "" {
eventData["description_rich"] = description
}
return eventData
}
@@ -120,8 +120,7 @@ var CalendarCreate = common.Shortcut{
{Name: "summary", Desc: "event title"},
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
{Name: "description", Desc: "deprecated: plain-text description; use --description-rich (Markdown) instead", Hidden: true},
{Name: "description-rich", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`).", Input: []string{common.File, common.Stdin}},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`).", Input: []string{common.File, common.Stdin}},
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -234,7 +233,7 @@ var CalendarCreate = common.Shortcut{
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
if err := resolveDescriptionRichImages(runtime, calendarId); err != nil {
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
return err
}

View File

@@ -170,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
if status, _ := out["status"].(string); status != "cancelled" {
delete(out, "status")
}
backfillDescriptionRich(out)
collapseDescription(out)
return out, nil
}

View File

@@ -988,8 +988,9 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured patch body: %v", err)
}
// The deprecated, hidden --description folds into description_rich; the CLI
// never sends the plain description field (mutually exclusive downstream).
// --description is the unified field, treated as rich text and sent as
// description_rich; the CLI never sends the plain description field
// (mutually exclusive downstream).
if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" {
t.Fatalf("unexpected patch body: %#v", body)
}
@@ -1411,18 +1412,17 @@ func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Read keeps both fields: description (plain) and description_rich (rich).
if !strings.Contains(out, "\"description\": \"[测试]\\n友情提醒\"") {
t.Errorf("expected plain description retained, got: %s", out)
}
if !strings.Contains(out, "\"description_rich\": \"友情提醒\"") {
t.Errorf("expected rich description surfaced, got: %s", out)
// Read exposes a single unified description field: it carries the rich
// (Markdown) value when present, and the plain text otherwise. The internal
// description_rich key is never surfaced.
if !strings.Contains(out, "\"description\": \"友情提醒\"") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description retained for plain-only event, got: %s", out)
t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
}
if !strings.Contains(out, "\"description_rich\": \"just text\"") {
t.Errorf("expected description_rich backfilled from plain, got: %s", out)
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
}
@@ -3438,7 +3438,8 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
}
func TestGet_UnifiesDescriptionRich(t *testing.T) {
// Read keeps both description (plain) and description_rich (rich).
// Read exposes a single unified description field carrying the rich value
// when present, and the plain text otherwise; description_rich is dropped.
t.Run("rich present", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
@@ -3462,17 +3463,16 @@ func TestGet_UnifiesDescriptionRich(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "\"description\": \"[表格]\"") {
t.Errorf("expected plain description retained, got: %s", out)
if !strings.Contains(out, "| a | b |") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if !strings.Contains(out, "\"description_rich\":") {
t.Errorf("expected description_rich in output, got: %s", out)
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
// When only a plain description exists, description_rich is backfilled from it
// and the plain description is still returned.
t.Run("only plain backfills rich", func(t *testing.T) {
// When only a plain description exists, it is surfaced under description.
t.Run("only plain surfaces under description", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
@@ -3495,10 +3495,10 @@ func TestGet_UnifiesDescriptionRich(t *testing.T) {
}
out := stdout.String()
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description retained, got: %s", out)
t.Errorf("expected plain description surfaced, got: %s", out)
}
if !strings.Contains(out, "\"description_rich\": \"just text\"") {
t.Errorf("expected description_rich backfilled from plain, got: %s", out)
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
}

View File

@@ -29,8 +29,7 @@ var CalendarUpdate = common.Shortcut{
{Name: "event-id", Desc: "event ID to update", Required: true},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "summary", Desc: "event title"},
{Name: "description", Desc: "deprecated: plain-text description; use --description-rich (Markdown) instead", Hidden: true},
{Name: "description-rich", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`).", Input: []string{common.File, common.Stdin}},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`). Passing an empty string clears the description.", Input: []string{common.File, common.Stdin}},
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -72,7 +71,7 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
return err
}
if !hasCalendarUpdateOperation(runtime) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --description-rich, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
}
return nil
}
@@ -114,10 +113,7 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
body["summary"] = runtime.Str("summary")
hasFields = true
}
if runtime.Cmd.Flags().Changed("description-rich") {
body["description_rich"] = runtime.Str("description-rich")
hasFields = true
} else if runtime.Cmd.Flags().Changed("description") {
if runtime.Cmd.Flags().Changed("description") {
body["description_rich"] = runtime.Str("description")
hasFields = true
}
@@ -362,11 +358,8 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
}
// Upload any local images referenced in --description-rich and rewrite them
// to drive URLs before the description is sent (the service cannot read
// local files).
if runtime.Cmd.Flags().Changed("description-rich") {
if err := resolveDescriptionRichImages(runtime, calendarID); err != nil {
if runtime.Cmd.Flags().Changed("description") {
if err := resolveDescriptionImages(runtime, calendarID); err != nil {
return err
}
}
@@ -443,19 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
if summary, _ := event["summary"].(string); summary != "" {
result["summary"] = summary
}
// Surface both description fields on read: description holds plain text,
// description_rich holds the rich (Markdown) version, backfilled from plain
// when the service returned no rich value.
description, _ := event["description"].(string)
if description != "" {
result["description"] = description
}
descriptionRich, _ := event["description_rich"].(string)
if descriptionRich == "" {
descriptionRich = description
}
if descriptionRich != "" {
result["description_rich"] = descriptionRich
if rich, _ := event["description_rich"].(string); rich != "" {
result["description"] = rich
} else if plain, _ := event["description"].(string); plain != "" {
result["description"] = plain
}
if start := formatCalendarEventTime(event["start_time"]); start != "" {
result["start"] = start

View File

@@ -27,8 +27,8 @@ const calendarMediaParentType = "calendar"
var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
func resolveDescriptionRichImages(runtime *common.RuntimeContext, calendarID string) error {
md := runtime.Str("description-rich")
func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
md := runtime.Str("description")
if md == "" || !strings.Contains(md, "![") {
return nil
}
@@ -37,8 +37,8 @@ func resolveDescriptionRichImages(runtime *common.RuntimeContext, calendarID str
return err
}
if changed {
if err := runtime.Cmd.Flags().Set("description-rich", rewritten); err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description-rich after image upload: %v", err).WithCause(err)
if err := runtime.Cmd.Flags().Set("description", rewritten); err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description after image upload: %v", err).WithCause(err)
}
}
return nil
@@ -85,8 +85,8 @@ func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt stri
safePath, err := validate.SafeInputPath(localPath)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description-rich image %q could not be read: %v", src, err).
WithParam("--description-rich").
"--description image %q could not be read: %v", src, err).
WithParam("--description").
WithHint("reference local images by a path inside the current working directory (e.g. ./images/pic.png; cd there first), or use an already-uploaded Lark image URL").
WithCause(err)
}
@@ -157,7 +157,7 @@ func localImagePath(src string) string {
}
func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string {
host := "internal-api-drive-stream.larkoffice.com"
host := "internal-api-drive-stream.feishu.cn"
if brand == core.BrandLark {
host = "internal-api-drive-stream.larksuite.com"
}

View File

@@ -68,7 +68,7 @@ func TestBuildCalendarImagePreviewURL(t *testing.T) {
brand core.LarkBrand
hostFrag string
}{
{core.BrandFeishu, "larkoffice"},
{core.BrandFeishu, "feishu.cn"},
{core.BrandLark, "larksuite"},
} {
raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568)
@@ -158,7 +158,7 @@ func TestCreate_UploadsLocalDescriptionImage(t *testing.T) {
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description-rich", "![pic](./pic.png)",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
@@ -233,7 +233,7 @@ func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description-rich", "![pic](./pic.png)",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
@@ -253,7 +253,7 @@ func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
}
// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path
// yields a typed --description-rich validation error before any API call.
// yields a typed --description validation error before any API call.
func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
@@ -263,7 +263,7 @@ func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description-rich", "![p](/etc/hosts)",
"--description", "![p](/etc/hosts)",
"--as", "bot",
}, f, stdout)
if runErr == nil {
@@ -273,7 +273,7 @@ func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
if !errors.As(runErr, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr)
}
if ve.Param != "--description-rich" {
t.Errorf("param = %q, want --description-rich", ve.Param)
if ve.Param != "--description" {
t.Errorf("param = %q, want --description", ve.Param)
}
}

View File

@@ -30,20 +30,23 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
return startInput, endInput
}
func backfillDescriptionRich(event map[string]interface{}) {
func collapseDescription(event map[string]interface{}) {
if event == nil {
return
}
if descRich, _ := event["description_rich"].(string); descRich == "" {
if desc, _ := event["description"].(string); desc != "" {
event["description_rich"] = desc
}
rich, _ := event["description_rich"].(string)
plain, _ := event["description"].(string)
delete(event, "description_rich")
switch {
case rich != "":
event["description"] = rich
case plain != "":
event["description"] = plain
default:
delete(event, "description")
}
}
func descriptionRichToSend(runtime *common.RuntimeContext) string {
if v := runtime.Str("description-rich"); v != "" {
return v
}
func descriptionToSend(runtime *common.RuntimeContext) string {
return runtime.Str("description")
}

View File

@@ -25,18 +25,6 @@ func (r *scopeCheckTokenResolver) ResolveToken(ctx context.Context, req credenti
return r.result, r.err
}
type scopeCheckAccountResolver struct {
appID string
}
func (r scopeCheckAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID}, nil
}
func newScopeCheckCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, scopeCheckAccountResolver{appID: appID}, tokenResolver, nil)
}
// TestEnhancePermissionError_TypedPermissionErrorRouted pins typed routing:
// an *errs.PermissionError gets enhanced regardless of its Message text,
// decoupling this helper from canonical-message rewrites that would
@@ -118,7 +106,7 @@ func TestEnhancePermissionError_PermissionErrorGetsScopeHint(t *testing.T) {
func TestCheckShortcutScopes_PropagatesContextCancellation(t *testing.T) {
f := &cmdutil.Factory{
Credential: newScopeCheckCredentialProvider("app-1", &scopeCheckTokenResolver{err: context.Canceled}),
Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{err: context.Canceled}, nil),
}
err := checkShortcutScopes(f, context.Background(), core.AsUser, &core.CliConfig{AppID: "app-1"}, []string{"im:message:read"})
@@ -136,9 +124,9 @@ func TestCheckShortcutScopes_PropagatesContextCancellation(t *testing.T) {
// command for human consumers.
func TestCheckShortcutScopes_ReturnsTypedPermissionError(t *testing.T) {
f := &cmdutil.Factory{
Credential: newScopeCheckCredentialProvider("app-1", &scopeCheckTokenResolver{
Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{
result: &credential.TokenResult{Token: "t", Scopes: "im:message:read calendar:calendar:read"},
}),
}, nil),
}
required := []string{"im:message:read", "drive:drive:read", "docx:document:read"}
@@ -180,7 +168,7 @@ func TestCheckShortcutScopes_ReturnsTypedPermissionError(t *testing.T) {
func TestCheckShortcutScopes_IgnoresNonContextTokenErrors(t *testing.T) {
f := &cmdutil.Factory{
Credential: newScopeCheckCredentialProvider("app-1", &scopeCheckTokenResolver{err: errors.New("token cache unavailable")}),
Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{err: errors.New("token cache unavailable")}, nil),
}
err := checkShortcutScopes(f, context.Background(), core.AsUser, &core.CliConfig{AppID: "app-1"}, []string{"im:message:read"})

View File

@@ -33,18 +33,6 @@ func (r *driveStatusScopedTokenResolver) ResolveToken(ctx context.Context, req c
return &credential.TokenResult{Token: "test-token", Scopes: r.scopes}, nil
}
type driveTestAccountResolver struct {
appID string
}
func (r driveTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID}, nil
}
func newDriveTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, driveTestAccountResolver{appID: appID}, tokenResolver, nil)
}
// TestDriveStatusCategorizesByHash exercises the four-bucket classification
// against a real walk of the temp dir and a mocked Drive listing.
func TestDriveStatusCategorizesByHash(t *testing.T) {
@@ -320,7 +308,7 @@ func TestDriveStatusQuickMarksUntrustedTimestampAsModified(t *testing.T) {
// requiring drive:file:download even after quick mode made download optional.
func TestDriveStatusExactRejectsMissingDownloadScope(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
f.Credential = newDriveTestCredentialProvider(driveTestConfig().AppID, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"})
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
@@ -369,7 +357,7 @@ func TestDriveStatusExactRejectsMissingDownloadScope(t *testing.T) {
// blocked on the exact-mode download scope precheck.
func TestDriveStatusQuickAcceptsMissingDownloadScope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
f.Credential = newDriveTestCredentialProvider(driveTestConfig().AppID, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"})
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)

View File

@@ -22,6 +22,7 @@ import (
"github.com/larksuite/cli/extension/fileio"
"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/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -1592,7 +1593,7 @@ func TestDriveSyncAskConflictEOFDuringPlanningPreventsAnyWrites(t *testing.T) {
func TestDriveSyncDryRunQuickAcceptsMetadataOnlyScope(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
f.Credential = newDriveTestCredentialProvider(driveTestConfig().AppID, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"})
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
@@ -1621,7 +1622,7 @@ func TestDriveSyncPreflightsActionScopesBeforeListing(t *testing.T) {
AppID: "drive-sync-download-scope-only", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, _ := cmdutil.TestFactory(t, syncTestConfig)
f.Credential = newDriveTestCredentialProvider(syncTestConfig.AppID, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly drive:file:download"})
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly drive:file:download"}, nil)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)

View File

@@ -329,7 +329,7 @@ func newDriveTaskResultRuntimeWithScopes(t *testing.T, as core.Identity, scopes
cfg := driveTestConfig()
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
factory.Credential = newDriveTestCredentialProvider(cfg.AppID, &mockDriveTaskResultTokenResolver{scopes: scopes})
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockDriveTaskResultTokenResolver{scopes: scopes}, nil)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, as)
runtime.Factory = factory
@@ -919,7 +919,7 @@ func TestValidateDriveTaskResultScopesPropagatesContextCancellation(t *testing.T
cfg := driveTestConfig()
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
factory.Credential = newDriveTestCredentialProvider(cfg.AppID, cancelingTokenResolver{})
factory.Credential = credential.NewCredentialProvider(nil, nil, cancelingTokenResolver{}, nil)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, core.AsUser)
runtime.Factory = factory

View File

@@ -27,14 +27,6 @@ func (s *staticConvertlibTokenResolver) ResolveToken(_ context.Context, _ creden
return &credential.TokenResult{Token: "test-token"}, nil
}
type convertlibTestAccountResolver struct {
appID string
}
func (r convertlibTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID}, nil
}
type convertlibRoundTripFunc func(*http.Request) (*http.Response, error)
func (f convertlibRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -76,12 +68,7 @@ func newBotConvertlibRuntime(t *testing.T, rt http.RoundTripper) *common.Runtime
AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
testCred := credential.NewCredentialProvider(
nil,
convertlibTestAccountResolver{appID: cfg.AppID},
&staticConvertlibTokenResolver{},
nil,
)
testCred := credential.NewCredentialProvider(nil, nil, &staticConvertlibTokenResolver{}, nil)
runtime := &common.RuntimeContext{
Config: cfg,
Factory: &cmdutil.Factory{

View File

@@ -37,18 +37,6 @@ func (s *staticShortcutTokenResolver) ResolveToken(_ context.Context, _ credenti
return &credential.TokenResult{Token: "tenant-token"}, nil
}
type imTestAccountResolver struct {
appID string
}
func (r imTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID}, nil
}
func newIMTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, imTestAccountResolver{appID: appID}, tokenResolver, nil)
}
type shortcutRoundTripFunc func(*http.Request) (*http.Response, error)
func (f shortcutRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -102,7 +90,7 @@ func newBotShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeCo
AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
testCred := newIMTestCredentialProvider(cfg.AppID, &staticShortcutTokenResolver{})
testCred := credential.NewCredentialProvider(nil, nil, &staticShortcutTokenResolver{}, nil)
runtime := &common.RuntimeContext{
Config: cfg,
Factory: &cmdutil.Factory{

View File

@@ -288,12 +288,12 @@ func (r errorTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSp
func setRuntimeScopes(t *testing.T, rt *common.RuntimeContext, scopes string) {
t.Helper()
rt.Factory.Credential = newIMTestCredentialProvider(rt.Config.AppID, scopedTokenResolver{scopes: scopes})
rt.Factory.Credential = credential.NewCredentialProvider(nil, nil, scopedTokenResolver{scopes: scopes}, nil)
}
func setRuntimeTokenError(t *testing.T, rt *common.RuntimeContext, err error) {
t.Helper()
rt.Factory.Credential = newIMTestCredentialProvider(rt.Config.AppID, errorTokenResolver{err: err})
rt.Factory.Credential = credential.NewCredentialProvider(nil, nil, errorTokenResolver{err: err}, nil)
}
func TestFlagMessageID(t *testing.T) {

View File

@@ -17,9 +17,10 @@ import (
)
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
// legacy synthetic-token prefix or a 28-character token whose interleaved
// product/region marker is "OFL0X". The backend requires
// "office_sheet_file" for those imported spreadsheets.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
@@ -27,22 +28,37 @@ const (
localOfficePrefix = "local_office_"
)
// officePrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
// officePrefixes are the legacy synthetic token prefixes an imported "office"
// spreadsheet may carry.
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken, mapping either the
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
// "office_sheet_file".
func sheetMediaParentType(spreadsheetToken string) string {
func isOfficeSpreadsheet(spreadsheetToken string) bool {
for _, prefix := range officePrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
return true
}
}
if len(spreadsheetToken) != 28 {
return false
}
// The five-character marker occupies positions 5, 10, 15, 20, and 25
// (1-based) in the interleaved token.
marker := []byte{
spreadsheetToken[4],
spreadsheetToken[9],
spreadsheetToken[14],
spreadsheetToken[19],
spreadsheetToken[24],
}
return string(marker) == "OFL0X"
}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken.
func sheetMediaParentType(spreadsheetToken string) string {
if isOfficeSpreadsheet(spreadsheetToken) {
return officeSheetFileParentType
}
return sheetImageParentType
}

View File

@@ -105,7 +105,7 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "fake_office_abc123",
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--file", "img.png",
"--dry-run", "--as", "user",
}, f, stdout)
@@ -117,10 +117,10 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
}
if !strings.Contains(out, `"office_sheet_file"`) {
t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out)
t.Fatalf("dry-run should include parent_type=office_sheet_file for interleaved OFL0X token, got: %s", out)
}
if strings.Contains(out, `"sheet_image"`) {
t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out)
t.Fatalf("dry-run must not emit sheet_image for interleaved OFL0X token, got: %s", out)
}
}
@@ -239,7 +239,7 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
}
// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported
// "office" spreadsheet (token prefixed with "fake_office_") uploads with
// "office" spreadsheet (token carrying the interleaved "OFL0X" marker) uploads with
// parent_type=office_sheet_file instead of the native sheet_image.
func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
dir := t.TempDir()
@@ -259,7 +259,7 @@ func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
}
reg.Register(stub)
const officeToken = "fake_office_abc123"
const officeToken = "aaaaOaaaaFaaaaLaaaa0aaaaXaaa"
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", officeToken,

View File

@@ -53,9 +53,10 @@ func sheetsInputStatError(flag string, err error) error {
}
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
// legacy synthetic-token prefix or a 28-character token whose interleaved
// product/region marker is "OFL0X". The backend requires
// "office_sheet_file" for those imported spreadsheets.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
@@ -63,21 +64,38 @@ const (
localOfficePrefix = "local_office_"
)
// officePrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
// officePrefixes are the legacy synthetic token prefixes an imported "office"
// spreadsheet may carry.
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
func isOfficeSpreadsheet(spreadsheetToken string) bool {
for _, prefix := range officePrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return true
}
}
if len(spreadsheetToken) != 28 {
return false
}
// The five-character marker occupies positions 5, 10, 15, 20, and 25
// (1-based) in the interleaved token.
marker := []byte{
spreadsheetToken[4],
spreadsheetToken[9],
spreadsheetToken[14],
spreadsheetToken[19],
spreadsheetToken[24],
}
return string(marker) == "OFL0X"
}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken. It is the single
// place that maps a spreadsheet token to its parent_type so every image-upload
// entry point (and its dry-run preview) stays consistent.
func sheetMediaParentType(spreadsheetToken string) string {
for _, prefix := range officePrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
}
if isOfficeSpreadsheet(spreadsheetToken) {
return officeSheetFileParentType
}
return sheetImageParentType
}

View File

@@ -25,8 +25,9 @@ import (
// TestSheetMediaParentType pins the token→parent_type mapping that every
// sheets image-upload entry point funnels through. Native spreadsheet tokens
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
// "local_office_" synthetic token and must upload with "office_sheet_file".
// use "sheet_image"; imported "office" spreadsheets use either a legacy
// prefix or the interleaved "OFL0X" marker and must upload with
// "office_sheet_file".
func TestSheetMediaParentType(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -40,6 +41,13 @@ func TestSheetMediaParentType(t *testing.T) {
{"fake_office token, only the prefix", fakeOfficePrefix, officeSheetFileParentType},
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
{"local_office token, only the prefix", localOfficePrefix, officeSheetFileParentType},
{"interleaved OFL0X office token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
{"interleaved exlcn token", "abcdeefghxijkllmnopcqrstnuv", sheetImageParentType},
{"interleaved shtcn native token", "abcdsefghhijkltmnopcqrstnuv", sheetImageParentType},
{"interleaved pptcn token", "abcdpefghpijkltmnopcqrstnuv", sheetImageParentType},
{"interleaved wodcn token", "abcdwefghoijkldmnopcqrstnuv", sheetImageParentType},
{"interleaved OFL0X marker with short length", "aaaaOaaaaFaaaaLaaaa0aaaaXaa", sheetImageParentType},
{"interleaved OFL0X marker with long length", "aaaaOaaaaFaaaaLaaaa0aaaaXaaaa", sheetImageParentType},
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
}
@@ -57,7 +65,7 @@ func TestSheetMediaParentType(t *testing.T) {
// to end (the Execute path the dry-run tests don't reach), asserting the
// parent_type that actually goes out on the wire is derived from the token: a
// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet
// (fake_office_-prefixed token) as office_sheet_file.
// (legacy prefix or interleaved OFL0X marker) as office_sheet_file.
func TestUploadSheetImage_ParentType(t *testing.T) {
cases := []struct {
name string
@@ -67,6 +75,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
{"interleaved OFL0X imported spreadsheet", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -16,6 +16,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
@@ -78,7 +79,7 @@ func newWikiDeleteSpaceRuntimeWithScopes(t *testing.T, as core.Identity, scopes
cfg := wikiTestConfig()
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
factory.Credential = newWikiTestCredentialProvider(cfg.AppID, &mockWikiMoveTokenResolver{scopes: scopes})
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockWikiMoveTokenResolver{scopes: scopes}, nil)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +delete-space"}, cfg, as)
runtime.Factory = factory

View File

@@ -27,18 +27,6 @@ type fakeWikiMoveNodeCall struct {
Spec wikiMoveSpec
}
type wikiTestAccountResolver struct {
appID string
}
func (r wikiTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID}, nil
}
func newWikiTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, wikiTestAccountResolver{appID: appID}, tokenResolver, nil)
}
type fakeWikiDocsToWikiMoveCall struct {
TargetSpaceID string
Spec wikiMoveSpec
@@ -155,7 +143,7 @@ func newWikiMoveRuntimeWithScopes(t *testing.T, as core.Identity, scopes string)
cfg := wikiTestConfig()
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
factory.Credential = newWikiTestCredentialProvider(cfg.AppID, &mockWikiMoveTokenResolver{scopes: scopes})
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockWikiMoveTokenResolver{scopes: scopes}, nil)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +move"}, cfg, as)
runtime.Factory = factory

View File

@@ -26,13 +26,12 @@ import (
// fakeExtProvider is a stub extcred.Provider for tests that returns a fixed token.
type fakeExtProvider struct {
appID string
token string
}
func (f *fakeExtProvider) Name() string { return "fake" }
func (f *fakeExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: f.appID}, nil
return nil, nil
}
func (f *fakeExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
return &extcred.Token{Value: f.token, Source: "fake"}, nil
@@ -382,7 +381,7 @@ func TestProxyHandler_AcceptsAllowedAuthHeaders(t *testing.T) {
// Use a handler with a real (fake) credential provider so we can
// distinguish auth-header reject (403) from later failures.
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{appID: "cli_test", token: "real-token"}},
[]extcred.Provider{&fakeExtProvider{token: "real-token"}},
nil, nil, nil,
)
h := &proxyHandler{
@@ -502,7 +501,7 @@ func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
upstreamHost := strings.TrimPrefix(upstream.URL, "https://")
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{appID: "cli_test", token: realToken}},
[]extcred.Provider{&fakeExtProvider{token: realToken}},
nil, nil, nil,
)

View File

@@ -16,14 +16,16 @@ metadata:
## 身份
日程操作默认使用 `--as user`(查看和管理当前用户的日程)。`--as bot` 只能访问 bot 自己的(空)日历,会拿到空结果——不要用 bot 身份查用户日程。
按**日程归属**选身份:
- 查看/管理登录用户本人的日程 → `--as user`(默认,绝大多数场景)。
- 查看/管理 bot 自己创建/拥有的日程 → `--as bot`
```bash
# BAD — bot 身份查用户日程,返回空列表
lark-cli calendar +agenda --as bot
# GOOD — user 身份查日程
# 用户本人日程 → user
lark-cli calendar +agenda --as user
# bot 自建或参与的日程 → bot
lark-cli calendar +agenda --as bot
```
## Shortcuts
@@ -48,7 +50,7 @@ lark-cli calendar +agenda --as user
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
```
读取日程时同时返回 `description`(纯文本)和 `description_rich`**Markdown** 富文本)两个字段:`description` 存纯文本,`description_rich`富文本仅有纯文本描述时`description_rich` 会用该纯文本兜底填充(两字段值相同)。创建/更新日程时只传 `description_rich`Markdown
日程描述统一使用 `description` 一个字段,按 **Markdown** 富文本处理。读取日程时 `description` 返回 Markdown 富文本仅有纯文本描述时返回该纯文本);创建/更新日程时也通过 `--description` 传入 Markdown。
### `+search-event` — 按关键词、时间范围和参会人搜索日程
@@ -188,6 +190,8 @@ lark-cli contact +search-user --query <query> --as user
lark-cli im +chat-search --query <query> --as user
```
> 搜索用户接口不支持 bot 身份,必须用 `--as user`;搜到的 `ou_` open_id 用于日程参与人操作(如添加日程参与人)。
## 不在本 skill 范围
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)

View File

@@ -32,20 +32,19 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
| `--summary <text>` | 否 | 日程标题。注意:标题中不应该出现时间、地点、人物信息 |
| `--start <time>` | 是 | 开始时间ISO 8601`2026-03-12T14:00+08:00` |
| `--end <time>` | 是 | 结束时间ISO 8601 |
| `--description-rich <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ``### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 `![图片名](图片URL)`(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL直接粘贴裸链接或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径``-`stdin读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*`。|
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`。AI 提取时请务必保留对应前缀 |
| `--description <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ``### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 `![图片名](图片URL)`(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL直接粘贴裸链接或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径``-`stdin读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*`。|
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`。AI 提取时请务必保留对应前缀。bot 可作为合法参会人,无需剔除 |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用主日历 |
| `--rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。示例值"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
> `--description-rich` 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
> `--description` 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
> 失败保护:若添加参会人失败(如 open_id 错误CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
> 搜索用户接口不支持 bot 身份,需用 `--as user` 进行搜索。
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
## 高级用法(完整 API 命令)

View File

@@ -28,8 +28,8 @@
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description-rich ...` | 更新原重复性日程的标题/描述等 |
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description-rich ...` (逐个) | 同步更新例外日程的对应字段 |
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description ...` | 更新原重复性日程的标题/描述等 |
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description ...` (逐个) | 同步更新例外日程的对应字段 |
> 理由:例外已脱离原重复性日程独立存在,不会自动继承原日程的更新。

View File

@@ -50,12 +50,13 @@ lark-cli calendar +room-find \
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名、会议室号或编号区间时使用。 |
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时提取数字放入此参数必须为正整数。 |
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID`ou_` 前缀)和群组 ID`oc_` 前缀),多个 ID 以逗号分隔。 |
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID`ou_` 前缀)和群组 ID`oc_` 前缀),多个 ID 以逗号分隔。**不要传入 bot 的 open_id**bot 是虚拟身份,不占会议室席位、无会议室偏好,传入只会干扰推荐结果。 |
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT如需限制重复次数必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai` |
## 规则
- 构造 `--attendee-ids` 前,先剔除 bot 参会人bot 不占席位、无偏好,不应参与会议室推荐。
- 多个 `--slot` 会由 CLI 内部并发调用单时间块接口,再聚合成一次输出
- `+room-find` 的时间输入必须是**确定时间块**,不是时间区间搜索。
- 如果是重复性日程,必须校验返回中的 `reserve_until_time`(该会议室最晚可预约时间)是否覆盖 `event-rrule` 对应的重复范围。

View File

@@ -39,6 +39,7 @@ lark-cli calendar +freebusy --start "<start>" --end "<end>"
```
规则:
- 参与人含 **bot**:无需为 bot 查询忙闲。bot 是虚拟身份,可并行多个会议、无忙闲语义,检查它没有意义。
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
- 参与人含**群组**:无需展开群组成员查询忙闲
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**

View File

@@ -45,7 +45,7 @@ lark-cli calendar +suggestion \
| ------------------------------- | ----- | ------------------------------------------------------------------- |
| `--start <time>` | 否 | 搜索区间开始时间(支持日期/ISO 8601等格式默认**当前时间** |
| `--end <time>` | 否 | 搜索区间结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔 |
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔。**不要传入 bot 的 open_id**bot 是虚拟身份,可并行多个会议、无忙闲语义,传入会干扰推荐时段的忙闲计算。 |
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT如需限制重复次数必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
| `--duration-minutes <min>` | 否 | 会议时长(分钟)。优先使用用户显式指定的值,若未指定则尝试根据上下文推断,推断失败则不传 |
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai` |

View File

@@ -12,7 +12,7 @@
lark-cli calendar +update \
--event-id "<EVENT_ID>" \
--summary "产品评审" \
--description-rich "评审需求范围、排期与风险" \
--description "评审需求范围、排期与风险" \
--start "2026-03-12T14:00+08:00" \
--end "2026-03-12T15:00+08:00"
@@ -43,7 +43,7 @@ lark-cli calendar +update \
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID详见 [重复性日程操作规范](lark-calendar-recurring.md) |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用 `primary` |
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
| `--description-rich <markdown>` | 否 | 新日程描述,统一使用此字段,格式为 **Markdown**(加粗、斜体、下划线 `<u>...</u>`、删除线、链接 `[文本](url)`、标题 `# `~`### `(最多三级)、引用 `> `、有序/无序列表、GFM 表格 `\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`、以及图片 `![图片名](图片URL)`(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL裸链接或 `[文本](url)`)会自动解析为内联文档,端上展示文档标题。支持 `@文件路径``-`stdin读取。仅在显式传入时更新传空字符串 `""` 会清空描述。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*`。 |
| `--description <markdown>` | 否 | 新日程描述,统一使用此字段,格式为 **Markdown**(加粗、斜体、下划线 `<u>...</u>`、删除线、链接 `[文本](url)`、标题 `# `~`### `(最多三级)、引用 `> `、有序/无序列表、GFM 表格 `\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`、以及图片 `![图片名](图片URL)`(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL裸链接或 `[文本](url)`)会自动解析为内联文档,端上展示文档标题。支持 `@文件路径``-`stdin读取。仅在显式传入时更新传空字符串 `""` 会清空描述。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*`。 |
| `--start <time>` | 否 | 新开始时间ISO 8601`2026-03-12T14:00+08:00`)。更新日程时间时必须同时传 `--end` |
| `--end <time>` | 否 | 新结束时间ISO 8601。更新日程时间时必须同时传 `--start` |
| `--rrule <rrule>` | 否 | 新重复规则RFC5545。**不要使用 COUNT如需限制次数推算后转为 UNTIL** |
@@ -52,17 +52,18 @@ lark-cli calendar +update \
| `--notify` | 否 | 是否发送更新通知,默认 `true`。可用 `--notify=false` 静默更新 |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
至少需要提供一个动作:`--summary``--description-rich``--start/--end``--rrule``--add-attendee-ids``--remove-attendee-ids`
至少需要提供一个动作:`--summary``--description``--start/--end``--rrule``--add-attendee-ids``--remove-attendee-ids`
## 使用规则
- `--add-attendee-ids` 是**增量添加**,不是替换最终参与人列表。不要用它表达“只保留这些人”。
-`--summary``--description-rich`CLI 以“是否显式传入该 flag”判断是否更新而不是以“值是否为空”判断如果显式传入空字符串会把对应字段清空。
- 日程描述统一走 `--description-rich`Markdown)。`--description`(纯文本)已废弃并从帮助中隐藏
-`--summary``--description`CLI 以“是否显式传入该 flag”判断是否更新而不是以“值是否为空”判断如果显式传入空字符串会把对应字段清空。
- 日程描述统一走 `--description`Markdown 富文本处理)
- 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**``*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*`
- 只想增删参会人或会议室时,不需要同时传 `--summary``--start``--end` 等日程字段。
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids``--remove-attendee-ids`
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`
- bot 可作为合法参会人添加,无需剔除。
- 会议室是 resource attendee必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。

View File

@@ -26,11 +26,16 @@
> **`--query` 最长 30 个字符**按字符数Unicode 码点)算,中文每字算 1 个,与 ASCII 同口径;超过 30 会被服务端拒绝(`99992402 field validation failed`**是报错不是截断**)。长关键词必须先压缩成核心实体 + 主题词(如把整句问题压成「项目名 + 主题」再搜),不要把整句原问塞进 `--query`。
>
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
>
> **标题词 + 正文词联合搜索**:如果用户同时给出标题关键词和正文关键词,并要求同一资源同时满足两项条件,优先执行一条普通联合搜索:`lark-cli drive +search --query "标题词 正文词"`,并在同一条命令中叠加用户指定的 `--folder-tokens`、`--doc-types` 等过滤条件。不要把这种联合搜索拆成“标题搜索 + 正文搜索”后自行拼交集;也不要把 `--only-title` 或 `intitle:` 用作主候选路径。只有用户明确只查标题时,才使用 `--only-title` 或 `intitle:`。
>
> 用户要求最终返回 N 条时N 是输出上限,不等于 `--page-size N`。逐页根据 `title` 和 `summary_highlighted` 保留同时满足两项条件的候选;有效候选不足 N 且 `has_more=true` 时,保持同一 query 和过滤条件,使用 `--page-token` 继续,最多检查 3 页。摘要不足以判断正文条件时,只对标题已匹配的候选串行读取正文,确认一个再处理下一个,找到 N 条后停止;不要并发拉取正文。检查 3 页后仍不足时,返回已确认结果并建议用户调整标题词、正文词或搜索范围,不要无界扫描。
### 自然语言 → 命令映射速查
| 用户说 | 命令 |
|---|---|
| 标题含某词且正文含某词,限定文件夹内最多 N 个结果N 为最终输出上限;按上文规则分页筛选,勿作为 `--page-size` | `lark-cli drive +search --query "标题词 正文词" --folder-tokens <FOLDER_TOKEN>` |
| 我这月创建的所有文档,按类型分类统计 | `lark-cli drive +search --query "" --created-by-me --created-since "<YYYY-MM-DD>" --created-until "<YYYY-MM-DD>"` |
| 最近半年我编辑过的文档,看看哪些最近更新过 | `lark-cli drive +search --query "" --edited-since 6m --sort edit_time` |
| 最近一个月我编辑过的文档 | `lark-cli drive +search --query "" --edited-since 1m` |
@@ -217,7 +222,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
- **日历表达**"上个月"、"上周"、"本月"、"前年"、"今年 3 月"等明确日历单位)→ **必须算出绝对 `YYYY-MM-DD` 边界**(如"上个月" = 上一个日历月的 1 号 → 当月 1 号),**不要近似成 `1m`/`2m`**CLI 里 `m` 是固定 30 天、`y` 固定 365 天,跟日历差 0-3 天,月末月初尤其容易偏出去
- 文档中的 `"<YYYY-MM-DD>"` 是运行时占位符:执行命令前按当前日期计算并替换。例如"本月"应替换为本月第一天和下月第一天,不要把示例生成时的月份硬编码进答案
- 绝对日期 → 直接 `YYYY-MM-DD` 或 RFC3339
- **分页策略**:默认只返回第一页,并说明 `has_more` 和下一页命令。只有用户明确要"全部 / 全量 / 继续翻"继续单轮翻页上限 5 页。
- **分页策略**:默认只返回第一页,并说明 `has_more` 和下一页命令。用户明确要"全部 / 全量 / 继续翻"继续;标题词 + 正文词联合搜索尚未找到足够的有效 Top N 候选时,按上文规则最多检查 3 页。其他场景单轮翻页上限 5 页。
- **原始返回**:用户要求"原始数据"、"接口返回"时用 `--format json`,不做客户端精确过滤或摘要重写。
## 权限

View File

@@ -1,7 +1,7 @@
---
name: lark-shared
version: 1.0.0
description: "Use for lark-cli setup/auth tasks: auth login/status/logout, user vs bot identity, business-domain permissions (--domain, including all/docs/drive), missing scopes, revoking authorization, handling _notice JSON, or pinning/clearing a profile/tenant identity for a task or session."
description: "Use for lark-cli setup/auth tasks: auth login/status/logout, user vs bot identity, business-domain permissions (--domain, including all/docs/drive), missing scopes, revoking authorization, or handling _notice JSON."
---
# lark-cli 共享规则
@@ -32,7 +32,7 @@ lark-cli config init --new
| 获取全部权限 | `lark-cli auth login --domain all --no-wait --json` |
| 按业务域授权 | `lark-cli auth login --domain docs --domain drive --no-wait --json``--domain` 可重复,也可用逗号分隔 |
| 指定单个 scope 授权 | `lark-cli auth login --scope "<scope>" --no-wait --json` |
| 检查当前登录态、是谁登录、token 是否有效 | 必须运行 `lark-cli auth status --json --verify`;回答时引用 `identity``verified``identities.user.status``identities.user.userName``identities.user.openId`(用户 open id`identities.user.tokenStatus``identities.user.scope` |
| 检查当前登录态、是谁登录、token 是否有效 | `lark-cli auth status --json --verify`;回答时引用 `identity``verified``identities.user.status``identities.user.userName``identities.user.openId`(用户 open id`identities.user.tokenStatus``identities.user.scope` |
| 快速查看当前身份状态 | `lark-cli whoami`;实际生效的那一个身份 |
| 退出当前机器的用户登录态 | `lark-cli auth logout --json``loggedOut:true` 表示注销成功 |
| bot 缺少权限 | 不要执行 `auth login`;引导用户在开发者后台开通 bot scope优先复用错误里的 `console_url` |
@@ -126,18 +126,6 @@ lark-cli auth login --device-code <device_code>
- **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL
- **禁止缓存 `verification_url``device_code`**:每次需要授权时,必须重新执行 `lark-cli auth login --no-wait --json` 生成新的链接。不要将授权链接和 device code 存入上下文供后续复用
## Profile 选择
- 查当前实际生效身份:`whoami --json`
- 查 OAuth 登录或 token 有效性:`auth status --json --verify`
- 为 agent 任务指定身份:每条 `lark-cli` 命令都加 `--profile <profile-or-appId>`
- 为同一 shell 的脚本或批处理指定身份:使用 `LARKSUITE_CLI_PROFILE=<profile-or-appId>`,或 export/unset
- 清除会话身份并恢复默认:使用 `unset LARKSUITE_CLI_PROFILE`;即使变量未设置,也向用户说明该操作。不要为此使用 `profile use``profile remove`
- 查已保存的配置:`config show``profile list`;它们不表示当前实际生效身份
- 永久修改默认 profile`profile use`
profile 不明确时先问用户。除非用户提供了直连凭证,否则不要设置 `LARKSUITE_CLI_APP_ID``LARKSUITE_CLI_APP_SECRET`
## 更新检查
lark-cli 命令执行后如果检测到新版本JSON 输出中会包含 `_notice.update` 字段(含 `message``command` 等)。

View File

@@ -194,14 +194,13 @@
<xs:simpleType name="FontSizeType">
<xs:annotation>
<xs:documentation>
字体大小, 使用正整数, 单位px
示例12, 14, 16, 18, 20, 24, 28, 32 等
字体大小, 浮点数, 范围 [1, 4000], 单位px
示例:10, 10.5, 12, 14, 16, 18, 20, 24, 28, 32 等
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:positiveInteger">
<xs:minInclusive value="6"/>
<xs:maxInclusive value="400"/>
<xs:pattern value="[0-9]+"/>
<xs:restriction base="xs:double">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="4000"/>
</xs:restriction>
</xs:simpleType>
@@ -211,6 +210,52 @@
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AutoStartAtType">
<xs:annotation>
<xs:documentation>
有序列表起始编号, 取值范围 [1, 32767]
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:positiveInteger">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="32767"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="BulletSizeType">
<xs:annotation>
<xs:documentation>
列表符号大小, 二选一:
- 百分比字符串(相对于文本字号), 取值范围 25%-400%, 如 "100%"
- 绝对像素值, 取值范围 6-400, 如 "14"
</xs:documentation>
</xs:annotation>
<xs:union>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="(2[5-9]|[3-9][0-9]|[1-3][0-9]{2}|400)%"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[6-9]|[1-9][0-9]|[1-3][0-9]{2}|400"/>
</xs:restriction>
</xs:simpleType>
</xs:union>
</xs:simpleType>
<xs:simpleType name="BulletCharType">
<xs:annotation>
<xs:documentation>
自定义列表符号, 如 "★", "→", "✓", "◆", 也支持 emoji
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<xs:minLength value="1"/>
<xs:maxLength value="8"/>
</xs:restriction>
</xs:simpleType>
<!-- 文本类型枚举 -->
<xs:simpleType name="TextType">
<xs:annotation>
@@ -232,6 +277,35 @@
</xs:restriction>
</xs:simpleType>
<!-- 动态文本字段类型枚举 -->
<xs:simpleType name="FieldType">
<xs:annotation>
<xs:documentation>
动态文本字段类型:
- slidenum: 当前幻灯片页码
- datetime: 默认日期时间格式
- datetime1-datetime13: 预定义日期时间格式
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<xs:enumeration value="slidenum"><xs:annotation><xs:documentation>当前幻灯片页码</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime"><xs:annotation><xs:documentation>浏览器默认日期格式, 例如 2026/7/14</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime1"><xs:annotation><xs:documentation>日期格式 M/D/YYYY, 例如 10/12/2007</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime2"><xs:annotation><xs:documentation>日期格式 dddd, MMMM D, YYYY, 例如 Friday, October 12, 2007</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime3"><xs:annotation><xs:documentation>日期格式 D MMMM YYYY, 例如 12 October 2007</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime4"><xs:annotation><xs:documentation>日期格式 MMMM D, YYYY, 例如 October 12, 2007</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime5"><xs:annotation><xs:documentation>日期格式 D-MMM-YY, 例如 12-Oct-07</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime6"><xs:annotation><xs:documentation>日期格式 MMMM YY, 例如 October 07</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime7"><xs:annotation><xs:documentation>日期格式 MMM-YY, 例如 Oct-07</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime8"><xs:annotation><xs:documentation>日期时间格式 M/D/YYYY h:mm A, 例如 10/12/2007 4:28 PM</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime9"><xs:annotation><xs:documentation>日期时间格式 M/D/YYYY h:mm:ss A, 例如 10/12/2007 4:28:34 PM</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime10"><xs:annotation><xs:documentation>时间格式 HH:mm, 例如 16:28</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime11"><xs:annotation><xs:documentation>时间格式 HH:mm:ss, 例如 16:28:34</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime12"><xs:annotation><xs:documentation>时间格式 h:mm A, 例如 4:28 PM</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime13"><xs:annotation><xs:documentation>时间格式 h:mm:ss A, 例如 4:28:34 PM</xs:documentation></xs:annotation></xs:enumeration>
</xs:restriction>
</xs:simpleType>
<!-- 文本对齐 -->
<xs:simpleType name="TextAlignType">
<xs:restriction base="xs:string">
@@ -781,21 +855,64 @@
<xs:attribute name="heightScale" type="sml:ArrowScaleType" use="optional"/>
</xs:complexType>
<!-- 裁剪方位枚举类型 -->
<xs:simpleType name="CropAnchorType">
<xs:annotation>
<xs:documentation>
裁剪方位枚举, 用于指定保留原图的哪个区域
- top: 保留顶部, 裁掉底部多余部分
- bottom: 保留底部, 裁掉顶部多余部分
- left: 保留左侧, 裁掉右侧多余部分
- right: 保留右侧, 裁掉左侧多余部分
居中场景不需要设置 anchor, 不设置 offset 即为默认居中裁剪
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<xs:enumeration value="top"><xs:annotation><xs:documentation>保留顶部, 裁掉底部多余部分</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="bottom"><xs:annotation><xs:documentation>保留底部, 裁掉顶部多余部分</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="left"><xs:annotation><xs:documentation>保留左侧, 裁掉右侧多余部分</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="right"><xs:annotation><xs:documentation>保留右侧, 裁掉左侧多余部分</xs:documentation></xs:annotation></xs:enumeration>
</xs:restriction>
</xs:simpleType>
<!-- 裁剪类型定义 -->
<xs:complexType name="CropType">
<xs:annotation>
<xs:documentation>
裁剪配置: 原图填充到预裁剪区域再根据offset裁出最终尺寸
裁剪配置: 原图裁剪到目标尺寸 (img 元素的 width × height)
可选属性:
type: 裁剪形状默认rect
leftOffset, rightOffset, topOffset, bottomOffset: 边缘偏移量(px)。正值向内裁剪负值向外扩展留白0值对齐边缘
presetHandlers: 控制点配置对应ECMA预设形状的控制点。单个或多个数字多个用逗号分隔。示例: type="rect"且presetHandlers="60"时为圆角矩形圆角半径60px
type: 裁剪形状, 默认 rect
anchor: 裁剪方位 (top/bottom/left/right), 参见 CropAnchorType
leftOffset / rightOffset / topOffset / bottomOffset: 四向偏移量 (px), 正值向内裁剪、负值向外扩展留白、0对齐边缘
presetHandlers: 控制点配置, 对应 ECMA 预设形状的控制点。单个或多个数字, 多个用逗号分隔。
示例: type="rect" 且 presetHandlers="60" 时为圆角矩形, 圆角半径 60px
说明: 指定offset时若预裁剪尺寸与原图比例不一致会产生拉伸变形。无法确定原图比例时不要指定offset
【推荐用法】使用 anchor 指定裁剪方位:
- 不设置 anchor 时: 默认按图片居中裁剪
- 设置 anchor 时: 按指定方位裁剪, 例如 anchor="top" 表示保留顶部、裁掉底部多余部分
- 使用 anchor 后, 不需要再设置 offset
- anchor 模式下原图按等比缩放后裁剪, 不会发生拉伸或压缩
【进阶用法】使用 offset 精细控制裁剪边界:
- 适用于用户在编辑器中手动调整裁剪、或从外部协议导入的场景
- 原图先填充到预裁剪区域, 再根据 offset 从四边裁出最终尺寸
- 注意: 若预裁剪尺寸与原图比例不一致会产生拉伸变形; 无法确定原图比例时, 不要指定 offset
【优先级】
如果同时设置了 anchor 和 offset, 以 anchor 为准, offset 被忽略
典型用法:
<crop/> 居中裁剪 (默认行为)
<crop anchor="top"/> 保留顶部
<crop anchor="left"/> 保留左侧
<crop type="rect" presetHandlers="60"/> 圆角矩形裁剪, 默认居中
</xs:documentation>
</xs:annotation>
<xs:attribute name="type" type="sml:ShapeType" use="optional" default="rect"/>
<xs:attribute name="anchor" type="sml:CropAnchorType" use="optional"/>
<xs:attribute name="leftOffset" type="xs:double" use="optional"/>
<xs:attribute name="rightOffset" type="xs:double" use="optional"/>
<xs:attribute name="topOffset" type="xs:double" use="optional"/>
@@ -1042,6 +1159,10 @@
- underline: content 级别是否下划线
- list: content 级别列表类型 bullet/number
- listStyle: content 级别列表样式
- bulletColor: 列表符号颜色(纯色), 可选
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)如 "100%", 或绝对像素值(取值范围 6-400如 "14"
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 作为后代段落的初始计数器, 子元素 &lt;p&gt;/&lt;ol&gt; 可通过自身 autoStartAt 重置
- bulletChar: 自定义列表符号字符, 可选, 如 "★", "→" 等, 设置后覆盖 listStyle 的符号
- anchorCenter: 控制文本对齐方式, 优先级高于 textAlign
- autoFit: 控制文本编辑溢出时处理策略
- baseline: 上标/下标, 相较于文本基线的偏移量
@@ -1049,6 +1170,13 @@
注意如果content子元素不指定属性, 默认继承content的属性值, 如果局部子元素指定了属性, 则使用局部属性值
autoStartAt 运行计数器示例(显式指定重置, 未指定沿用前序计数器):
&lt;content autoStartAt="5"&gt;
&lt;p list="number"&gt;A&lt;/p&gt; &lt;!-- A=5, 继承 content 初始值 --&gt;
&lt;p list="number" autoStartAt="10"&gt;B&lt;/p&gt; &lt;!-- B=10, 本段显式重置 --&gt;
&lt;p list="number"&gt;C&lt;/p&gt; &lt;!-- C=11, 沿用前序计数器递增 --&gt;
&lt;/content&gt;
子元素:
- p: 段落元素
- ul: 无序列表元素
@@ -1085,6 +1213,10 @@
<xs:attribute name="underline" type="xs:boolean" />
<xs:attribute name="list" type="sml:ListType" default="none"/>
<xs:attribute name="listStyle" type="sml:ListStyleType" />
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
<xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
<xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
<xs:attribute name="anchorCenter" type="xs:boolean" default="false" /> <!-- 控制竖排文字是否在垂直方向保持居中 -->
<xs:attribute name="autoFit" type="sml:AutoFitType" default="no-auto-fit" />
<xs:attribute name="wrap" type="xs:boolean" default="true" />
@@ -1096,9 +1228,9 @@
<xs:annotation>
<xs:documentation>
段落容器, 支持富文本内容
可包含纯文本和内联格式元素(br/strong/em/u/span/del/a/shadow/outline)
可包含纯文本和内联格式元素(br/strong/em/u/span/del/a/shadow/outline/formula/field)
内联元素嵌套:所有内联元素均可包含纯文本或其他内联元素,以实现复杂的格式组合
元素自嵌套除a元素外其余内联元素支持自身嵌套当shadow和outline自嵌套时渲染效果遵循就近原则以内层定义的样式为准
元素自嵌套除a/formula元素外其余内联元素支持自身嵌套当shadow和outline自嵌套时渲染效果遵循就近原则以内层定义的样式为准
空格处理规则:
- 文本内的连续空格会被合并为单个空格
@@ -1119,6 +1251,8 @@
- a: 超链接
- shadow: 文本阴影
- outline: 文本轮廓
- formula: 科学公式(支持数学、物理等)
- field: 动态文本字段,元素内容作为不支持动态字段时的降级文本
属性说明:
- textAlign: 文本对齐方式
- lineSpacing: 行间距
@@ -1127,6 +1261,10 @@
- level: 段落级别, 取值范围 [1,10]
- list: 列表类型(bullet/number)
- listStyle: 列表样式
- bulletColor: 列表符号颜色(纯色), 可选
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 显式指定时从当前段落起重置计数器, 未指定时沿用同一 content 内的前序计数器
- bulletChar: 自定义列表符号字符, 可选
- marginLeft: 段落左侧缩进宽度
- indent: 首行缩进宽度
</xs:documentation>
@@ -1134,6 +1272,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1142,6 +1281,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
<xs:attribute name="textAlign" type="sml:TextAlignType" />
<xs:attribute name="lineSpacing" type="sml:LineSpacingType" default="multiple:1.5"/>
@@ -1151,6 +1291,10 @@
<xs:attribute name="level" type="sml:LevelType" default="1"/>
<xs:attribute name="list" type="sml:ListType" default="none"/>
<xs:attribute name="listStyle" type="sml:ListStyleType"/>
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
<xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
<xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
<xs:attribute name="marginLeft" type="xs:double" use="optional" />
<xs:attribute name="indent" type="sml:NonNegativeDouble" use="optional"/>
</xs:complexType>
@@ -1160,7 +1304,14 @@
<!-- 无序列表 -->
<xs:element name="ul">
<xs:annotation>
<xs:documentation>无序列表</xs:documentation>
<xs:documentation>
无序列表
属性说明:
- listStyle: 列表样式
- bulletColor: 列表符号颜色(纯色), 可选
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400
- bulletChar: 自定义列表符号字符, 可选, 设置后覆盖 listStyle 的符号
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
@@ -1173,13 +1324,23 @@
</xs:element>
</xs:sequence>
<xs:attribute name="listStyle" type="sml:UnorderedListStyle" default="circle-hollow-square"/>
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
<xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
</xs:complexType>
</xs:element>
<!-- 有序列表 -->
<xs:element name="ol">
<xs:annotation>
<xs:documentation>有序列表, 可指定序号</xs:documentation>
<xs:documentation>
有序列表, 可指定序号
属性说明:
- listStyle: 列表样式
- bulletColor: 列表符号颜色(纯色), 可选
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 作用于本列表组的计数器初始值, 子元素 &lt;li@index&gt; 可覆盖单项编号
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
@@ -1193,6 +1354,9 @@
</xs:element>
</xs:sequence>
<xs:attribute name="listStyle" type="sml:OrderedListStyle" default="number-lower-alpha-lower-roman"/>
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
<xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
</xs:complexType>
</xs:element>
@@ -1383,7 +1547,7 @@
alpha: 不透明度[0, 1]
可选子元素:
crop: 裁剪。无标签或所有offset未设置时从左上角自适应裁到width×height
crop: 裁剪。无标签 / 空标签 / 仅设 anchor 时按等比缩放后裁剪到 width×height; anchor 指定保留方位 (top/bottom/left/right), 不设 anchor 即居中裁剪; offset 用于精细控制
reflection: 倒影。无标签代表无倒影,空标签代表使用默认样式
shadow: 阴影。无标签代表无阴影,空标签代表使用默认样式
border: 边框。无标签代表无边框,空标签代表使用默认样式(颜色: rgba(43, 47, 54, 1), 宽度: 2)
@@ -1500,7 +1664,7 @@
td 子元素:
- borderTop/borderRight/borderBottom/borderLeft: 单元格边框样式, 无border标签代表无边框, 空border标签代表使用默认样式(实线边框, 颜色为rgba(221, 222, 223, 1), 宽度为1)
- fill: 单元格填充样式, 无fill标签代表不填充, 空fill标签代表使用默认样式(默认颜色填充, 颜色为rgba(255, 255, 255, 1))
- content: 单元格内容
- content: 单元格内容。内容默认不反向修改表格几何尺寸; 当内容高度大于当前行高时, 需要手动修改行高
</xs:documentation>
</xs:annotation>
<xs:complexType>
@@ -1558,13 +1722,15 @@
<xs:complexType/>
</xs:element>
<xs:element name="strong">
<xs:element name="field">
<xs:annotation>
<xs:documentation>粗体/加重文本</xs:documentation>
<xs:documentation>
动态文本字段。
type 属性描述动态语义,元素内容是静态降级文本,可包含行内样式元素。
</xs:documentation>
</xs:annotation>
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1574,6 +1740,70 @@
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
</xs:choice>
<xs:attribute name="type" type="sml:FieldType" use="required"/>
</xs:complexType>
</xs:element>
<xs:element name="formula">
<xs:annotation>
<xs:documentation>
通用公式元素。
用于展示各类科学公式。
结构说明:
- 必须从支持的公式格式中选择且仅选择一种作为子元素。
- 当前版本支持格式:&lt;latex&gt;
示例:
- 基础公式:
&lt;formula&gt;
&lt;latex&gt;&lt;![CDATA[ E = mc^2 ]]&gt;&lt;/latex&gt;
&lt;/formula&gt;
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:choice minOccurs="1" maxOccurs="1">
<xs:element name="latex">
<xs:annotation>
<xs:documentation>
LaTeX 格式的公式内容。
本元素包含的 LaTeX 字符串必须严格符合附件中定义的宏集范围。
内容语法:
- 语法范围:仅使用附件白名单中明确支持的宏。
- 表达建议:优先使用基础运算符、分式(\frac)、根号(\sqrt)、矩阵(matrix)等标准数学环境。
- 格式要求:必须使用 CDATA 包裹内容,且 CDATA 内部严禁进行 XML 转义(如 &amp;lt;, &amp;amp;)。
- 空白处理:解析器将保留 CDATA 内的所有换行和缩进,建议利用此特性保持 LaTeX 源码的结构化和可读性。
</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string"/>
</xs:simpleType>
</xs:element>
</xs:choice>
</xs:complexType>
</xs:element>
<xs:element name="strong">
<xs:annotation>
<xs:documentation>粗体/加重文本</xs:documentation>
</xs:annotation>
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
<xs:element ref="sml:span"/>
<xs:element ref="sml:del"/>
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:complexType>
</xs:element>
@@ -1590,6 +1820,7 @@
<xs:extension base="sml:ShadowType">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1598,6 +1829,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:extension>
</xs:complexContent>
@@ -1617,6 +1849,7 @@
<xs:extension base="sml:OutlineType">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1625,6 +1858,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:extension>
</xs:complexContent>
@@ -1638,6 +1872,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1646,6 +1881,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:complexType>
</xs:element>
@@ -1657,6 +1893,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1665,6 +1902,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:complexType>
</xs:element>
@@ -1676,6 +1914,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1684,6 +1923,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:complexType>
</xs:element>
@@ -1698,6 +1938,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1706,6 +1947,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
<xs:attribute name="color" type="sml:Color" use="optional"/>
<xs:attribute name="backgroundColor" type="sml:Color" use="optional"/>
@@ -1729,6 +1971,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1736,6 +1979,7 @@
<xs:element ref="sml:del"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
<xs:attribute name="href" use="required">
<xs:simpleType>
@@ -1823,36 +2067,116 @@
<!-- 有序列表样式枚举 -->
<xs:simpleType name="OrderedListStyle">
<xs:annotation>
<xs:documentation>有序列表样式</xs:documentation>
<xs:documentation>
有序列表样式
分为两类:
1. 复合样式(按层级循环不同格式):如 number-lower-alpha-lower-roman 表示第1级用数字、第2级用小写字母、第3级用小写罗马超过层级数后循环
2. 单一样式(所有层级使用同一格式,不循环):以 PPTX 标准 scheme 命名,如 alpha-lc-paren-both 表示所有层级都用 (a)(b)(c) 格式
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<!-- 复合样式(按层级循环) -->
<xs:enumeration value="number-lower-alpha-lower-roman"><xs:annotation><xs:documentation>1. a. i. - 数字/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="number-lower-alpha-lower-roman-paren"><xs:annotation><xs:documentation>1) a) i) - 带括号版本</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hierarchical-number"><xs:annotation><xs:documentation>1. 1.1. 1.1.1. - 多级数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="upper-alpha-lower-alpha-lower-roman"><xs:annotation><xs:documentation>A. a. i. - 大写字母/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="upper-roman-upper-alpha-number"><xs:annotation><xs:documentation>I. A. 1. - 大写罗马/大写字母/数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="zero-padded-lower-alpha-lower-roman"><xs:annotation><xs:documentation>01. a. i. - 补零数字/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="circle-number"><xs:annotation><xs:documentation> 圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="circle-number"><xs:annotation><xs:documentation>圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="lower-alpha-paren"><xs:annotation><xs:documentation>a) b) c) - 小写字母带括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="lower-alpha-dot"><xs:annotation><xs:documentation>a. b. c. - 小写字母带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="chinese-formal"><xs:annotation><xs:documentation>一、二、三、 - 中文数字</xs:documentation></xs:annotation></xs:enumeration>
<!-- 单一样式(所有层级使用同一格式,不随层级循环) -->
<!-- 拉丁字母 Latin -->
<xs:enumeration value="alpha-lc-paren-both"><xs:annotation><xs:documentation>(a) (b) (c) - 小写字母带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-uc-paren-both"><xs:annotation><xs:documentation>(A) (B) (C) - 大写字母带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-lc-paren-r"><xs:annotation><xs:documentation>a) b) c) - 小写字母带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-uc-paren-r"><xs:annotation><xs:documentation>A) B) C) - 大写字母带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-lc-period"><xs:annotation><xs:documentation>a. b. c. - 小写字母带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-uc-period"><xs:annotation><xs:documentation>A. B. C. - 大写字母带点</xs:documentation></xs:annotation></xs:enumeration>
<!-- 阿拉伯数字 Arabic Numeral -->
<xs:enumeration value="arabic-paren-both"><xs:annotation><xs:documentation>(1) (2) (3) - 数字带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-paren-r"><xs:annotation><xs:documentation>1) 2) 3) - 数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-period"><xs:annotation><xs:documentation>1. 2. 3. - 数字带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-plain"><xs:annotation><xs:documentation>1 2 3 - 纯数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-db-period"><xs:annotation><xs:documentation>- 全角数字带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-db-plain"><xs:annotation><xs:documentation> - 全角纯数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic1-minus"><xs:annotation><xs:documentation>أ- ب- ت- - 阿拉伯语字母(现代序)带后横线</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic2-minus"><xs:annotation><xs:documentation>-أ- -ب- -ج- - 阿拉伯语字母(Abjadi序)带双横线</xs:documentation></xs:annotation></xs:enumeration>
<!-- 罗马数字 Roman -->
<xs:enumeration value="roman-lc-paren-both"><xs:annotation><xs:documentation>(i) (ii) (iii) - 小写罗马带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-uc-paren-both"><xs:annotation><xs:documentation>(I) (II) (III) - 大写罗马带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-lc-paren-r"><xs:annotation><xs:documentation>i) ii) iii) - 小写罗马带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-uc-paren-r"><xs:annotation><xs:documentation>I) II) III) - 大写罗马带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-lc-period"><xs:annotation><xs:documentation>i. ii. iii. - 小写罗马带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-uc-period"><xs:annotation><xs:documentation>I. II. III. - 大写罗马带点</xs:documentation></xs:annotation></xs:enumeration>
<!-- 圆圈数字 Circle -->
<xs:enumeration value="circle-num-db-plain"><xs:annotation><xs:documentation>① ② ③ - 圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="circle-num-wd-black-plain"><xs:annotation><xs:documentation>❶ ❷ ❸ - 实心圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="circle-num-wd-white-plain"><xs:annotation><xs:documentation>① ② ③ - 圆圈数字1-10 循环, 字形与 circle-num-db-plain 相同但超过 10 后不降级为纯数字)</xs:documentation></xs:annotation></xs:enumeration>
<!-- 东亚 East Asian -->
<xs:enumeration value="ea1-chs-period"><xs:annotation><xs:documentation>一. 二. 三. - 简体中文带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-chs-plain"><xs:annotation><xs:documentation>一 二 三 - 简体中文</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-cht-period"><xs:annotation><xs:documentation>一. 二. 三. - 繁体中文带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-cht-plain"><xs:annotation><xs:documentation>一 二 三 - 繁体中文</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-jpn-chs-db-period"><xs:annotation><xs:documentation>一.二.三.- CJK汉字数字带全角点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-jpn-kor-plain"><xs:annotation><xs:documentation>一 二 三 - CJK汉字数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-jpn-kor-period"><xs:annotation><xs:documentation>一. 二. 三. - CJK汉字数字带半角点</xs:documentation></xs:annotation></xs:enumeration>
<!-- 希伯来语 Hebrew -->
<xs:enumeration value="hebrew2-minus"><xs:annotation><xs:documentation>א- ב- ג- - 希伯来字母带横线</xs:documentation></xs:annotation></xs:enumeration>
<!-- 泰语 Thai -->
<xs:enumeration value="thai-alpha-period"><xs:annotation><xs:documentation>ก. ข. ค. - 泰语字母带点(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-alpha-paren-r"><xs:annotation><xs:documentation>ก) ข) ค) - 泰语字母带右括号(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-alpha-paren-both"><xs:annotation><xs:documentation>(ก) (ข) (ค) - 泰语字母带双括号(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-num-period"><xs:annotation><xs:documentation>๑. ๒. ๓. - 泰语数字带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-num-paren-r"><xs:annotation><xs:documentation>๑) ๒) ๓) - 泰语数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-num-paren-both"><xs:annotation><xs:documentation>(๑) (๒) (๓) - 泰语数字带双括号</xs:documentation></xs:annotation></xs:enumeration>
<!-- 印地语 Hindi -->
<xs:enumeration value="hindi-alpha-period"><xs:annotation><xs:documentation>अ. आ. इ. - 印地语元音字母带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hindi-num-period"><xs:annotation><xs:documentation>१. २. ३. - 印地语数字带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hindi-num-paren-r"><xs:annotation><xs:documentation>१) २) ३) - 印地语数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hindi-alpha1-period"><xs:annotation><xs:documentation>क. ख. ग. - 印地语辅音字母带点</xs:documentation></xs:annotation></xs:enumeration>
</xs:restriction>
</xs:simpleType>
<!-- 无序列表样式枚举 -->
<xs:simpleType name="UnorderedListStyle">
<xs:annotation>
<xs:documentation>无序列表样式</xs:documentation>
<xs:documentation>
无序列表样式
分为两类:
1. 复合样式(按层级循环不同图标):如 circle-hollow-square 表示第1级实心圆、第2级空心圆、第3级实心方形超过层级数后循环
2. 单一样式(所有层级使用同一图标,不循环):以 pptx- 前缀命名,如 pptx-circle 表示所有层级都用 ● 实心圆
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<!-- 复合样式(按层级循环) -->
<xs:enumeration value="circle-hollow-square"><xs:annotation><xs:documentation>实心圆 空心圆 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="diamond-triangle-square"><xs:annotation><xs:documentation>形 三角形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="diamond-triangle-square"><xs:annotation><xs:documentation>形 三角形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hollow-square-all"><xs:annotation><xs:documentation>空心方形 空心方形 空心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arrow-diamond-circle"><xs:annotation><xs:documentation>右箭头 实心形 实心圆形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arrow-diamond-circle"><xs:annotation><xs:documentation>右箭头 实心形 实心圆形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="star-hollow-circle-square"><xs:annotation><xs:documentation>实心五角星 空心圆形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="triangle-hollow-circle-square"><xs:annotation><xs:documentation>三角形 空心圆形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="solid-square-all"><xs:annotation><xs:documentation>实心方形 实心方形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="solid-diamond-all"><xs:annotation><xs:documentation>实心菱形 实心菱形 实心菱形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="check-all"><xs:annotation><xs:documentation>对勾 对勾 对勾</xs:documentation></xs:annotation></xs:enumeration>
<!-- 单一样式(所有层级使用同一图标,不随层级循环) -->
<xs:enumeration value="pptx-circle"><xs:annotation><xs:documentation>● 实心圆(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-square"><xs:annotation><xs:documentation>■ 方块(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-diamond"><xs:annotation><xs:documentation>◆ 菱形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-square-empty"><xs:annotation><xs:documentation>□ 空心方框(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-check"><xs:annotation><xs:documentation>✓ 对勾(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-triangle"><xs:annotation><xs:documentation>► 右三角(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-bullet"><xs:annotation><xs:documentation>• 小圆点(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-circle-empty"><xs:annotation><xs:documentation>○ 空心圆(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-diamond-empty"><xs:annotation><xs:documentation>◇ 空心菱形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-arrow-right"><xs:annotation><xs:documentation>➔ 右箭头(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-star"><xs:annotation><xs:documentation>★ 星形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-square-shadow"><xs:annotation><xs:documentation>❑ 带右下阴影的 3D 方框(所有层级,对应 PPTX Wingdings 'q'</xs:documentation></xs:annotation></xs:enumeration>
</xs:restriction>
</xs:simpleType>
@@ -2096,6 +2420,19 @@
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="ChartGradientKindType">
<xs:annotation>
<xs:documentation>
图表渐变类型
可选值: linear(线性渐变) | radial(径向渐变)
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<xs:enumeration value="linear"/>
<xs:enumeration value="radial"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="ChartRadarShapeType">
<xs:annotation>
<xs:documentation>
@@ -2217,6 +2554,7 @@
属性:
- textAlign: 文本对齐方式(left|center|right), 默认left
- fontFamily: 字体族名称,仅图表根级主标题/副标题支持;坐标轴标题不支持
- fontSize: 字号大小
- bold: 是否加粗
- italic: 是否斜体, 默认false
@@ -2230,6 +2568,7 @@
<xs:complexContent>
<xs:extension base="sml:ChartFontStyleType">
<xs:attribute name="textAlign" type="sml:ChartTextAlignType" use="optional" default="left"/>
<xs:attribute name="fontFamily" type="sml:FontFamilyType" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
@@ -2298,10 +2637,10 @@
图表背景配置
属性:
- color: 背景颜色, 默认透明 rgba(0,0,0,0)
- color: 背景颜色,省略时使用图表默认背景;无填充可使用透明 rgba(0,0,0,0)
</xs:documentation>
</xs:annotation>
<xs:attribute name="color" type="sml:SolidColor" use="optional" default="rgb(255, 255, 255)"/>
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartBorderType">
@@ -2311,7 +2650,7 @@
属性:
- color: 边框颜色,默认 rgb(222, 224, 227)
- width: 边框宽度(像素), 默认 1
- width: 边框宽度(像素), 默认 1无边框可设置为0或不设置chartBorder
- style: 边框样式(solid|dashed|dotted), 默认 solid
- radius: 圆角半径(像素), 默认 6
</xs:documentation>
@@ -2322,6 +2661,61 @@
<xs:attribute name="radius" type="xs:nonNegativeInteger" use="optional" />
</xs:complexType>
<xs:complexType name="ChartGradientStopType">
<xs:annotation>
<xs:documentation>
图表渐变色标
属性:
- offset: 色标位置比例[0,1]
- color: 色标颜色
- opacity: 色标透明度[0,1]
</xs:documentation>
</xs:annotation>
<xs:attribute name="offset" type="sml:RatioType" use="required"/>
<xs:attribute name="color" type="sml:SolidColor" use="required"/>
<xs:attribute name="opacity" type="sml:RatioType" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartGradientStopsType">
<xs:annotation>
<xs:documentation>
图表渐变色标列表至少需要2个色标
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="stop" type="sml:ChartGradientStopType" minOccurs="2" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChartGradientType">
<xs:annotation>
<xs:documentation>
图表渐变配置
属性:
- type: 渐变类型(linear|radial)
- x0/y0/x1/y1: 线性渐变起止点坐标
- r0/r1: 径向渐变半径
- gradientMethod: 渐变算法/插值方式
子元素:
- stops: 渐变色标列表
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="stops" type="sml:ChartGradientStopsType" minOccurs="1"/>
</xs:sequence>
<xs:attribute name="type" type="sml:ChartGradientKindType" use="required"/>
<xs:attribute name="x0" type="xs:double" use="optional"/>
<xs:attribute name="y0" type="xs:double" use="optional"/>
<xs:attribute name="x1" type="xs:double" use="optional"/>
<xs:attribute name="y1" type="xs:double" use="optional"/>
<xs:attribute name="r0" type="xs:double" use="optional"/>
<xs:attribute name="r1" type="xs:double" use="optional"/>
<xs:attribute name="gradientMethod" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartColorThemeType">
<xs:annotation>
<xs:documentation>
@@ -2431,12 +2825,16 @@
- size: 该系列所有点的大小
子元素:
- fillGradient: 该系列所有点的填充渐变(可选)
- strokeGradient: 该系列所有点的边框/描边渐变(可选)
- chartPoint: 单个数据点配置(可选, 多个), 用于覆盖特定点的样式
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalPointsType">
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="chartPoint" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:annotation>
@@ -2448,8 +2846,14 @@
- color: 该点的颜色
- shape: 该点的形状(circle|square|triangle|diamond|rect)
- size: 该点的大小(像素)
子元素:
- fillGradient: 该点填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
<xs:attribute name="shape" type="sml:ChartPointShapeType" use="optional"/>
@@ -2462,7 +2866,7 @@
</xs:complexType>
<!-- 线条配置 -->
<xs:complexType name="ChartLineType">
<xs:complexType name="ChartGlobalLineType">
<xs:annotation>
<xs:documentation>
图表全局线条配置(第一层:所有系列的默认样式)
@@ -2479,8 +2883,27 @@
<xs:attribute name="style" type="sml:ChartLineStyleType" use="optional" default="solid"/>
</xs:complexType>
<xs:complexType name="ChartSeriesLineType">
<xs:annotation>
<xs:documentation>
图表系列线条配置(第二层:单系列统一配置)
继承ChartGlobalLineType的所有属性
子元素:
- strokeGradient: 该系列线条渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalLineType">
<xs:sequence>
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!-- 面积配置 -->
<xs:complexType name="ChartAreaType">
<xs:complexType name="ChartGlobalAreaType">
<xs:annotation>
<xs:documentation>
图表全局面积配置(第一层:所有系列的默认填充样式)
@@ -2493,6 +2916,25 @@
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartSeriesAreaType">
<xs:annotation>
<xs:documentation>
图表系列面积配置(第二层:单系列统一配置)
继承ChartGlobalAreaType的所有属性
子元素:
- fillGradient: 该系列面积填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalAreaType">
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!-- 柱子配置 -->
<xs:complexType name="ChartGlobalBarsType">
<xs:annotation>
@@ -2533,12 +2975,16 @@
- borderStyle: 该系列所有柱子的边框样式
子元素:
- fillGradient: 该系列所有柱子的填充渐变(可选)
- strokeGradient: 该系列所有柱子的边框渐变(可选)
- chartBar: 单个柱子配置(可选, 多个), 用于覆盖特定柱子的样式
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalBarsType">
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="chartBar" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:annotation>
@@ -2551,8 +2997,14 @@
- borderColor: 该柱子的边框颜色
- borderWidth: 该柱子的边框宽度(像素)
- borderStyle: 该柱子的边框样式(solid|dashed|dotted)
子元素:
- fillGradient: 该柱子的填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
@@ -2577,8 +3029,14 @@
- offsetRadius: 扇区径向偏移比例[0,1], 用于突出显示
- borderColor: 扇区边框颜色
- color: 扇区填充颜色
子元素:
- fillGradient: 扇区填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
<xs:attribute name="offsetRadius" type="sml:RatioType" use="optional"/>
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
@@ -2598,10 +3056,12 @@
- startAngle: 起始角度[0,360), 控制第一个扇区的起始位置, 默认0
子元素:
- fillGradient: 所有扇区的统一填充渐变(可选)
- chartSector: 单个扇区配置(可选, 多个), 用于定制特定扇区
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="chartSector" type="sml:ChartSectorType" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
@@ -2648,8 +3108,8 @@
</xs:annotation>
<xs:sequence>
<xs:element name="chartPoints" type="sml:ChartSeriesPointsType" minOccurs="0"/>
<xs:element name="chartLine" type="sml:ChartLineType" minOccurs="0"/>
<xs:element name="chartArea" type="sml:ChartAreaType" minOccurs="0"/>
<xs:element name="chartLine" type="sml:ChartSeriesLineType" minOccurs="0"/>
<xs:element name="chartArea" type="sml:ChartSeriesAreaType" minOccurs="0"/>
<xs:element name="chartBars" type="sml:ChartSeriesBarsType" minOccurs="0"/>
<xs:element name="chartSectors" type="sml:ChartSectorsType" minOccurs="0"/>
<xs:element name="chartLabels" type="sml:ChartDataLabelsType" minOccurs="0"/>
@@ -2850,8 +3310,8 @@
</xs:annotation>
<xs:all>
<xs:element name="chartPoints" type="sml:ChartGlobalPointsType" minOccurs="0"/>
<xs:element name="chartLines" type="sml:ChartLineType" minOccurs="0"/>
<xs:element name="chartAreas" type="sml:ChartAreaType" minOccurs="0"/>
<xs:element name="chartLines" type="sml:ChartGlobalLineType" minOccurs="0"/>
<xs:element name="chartAreas" type="sml:ChartGlobalAreaType" minOccurs="0"/>
<xs:element name="chartBars" type="sml:ChartGlobalBarsType" minOccurs="0"/>
<xs:element name="chartLabels" type="sml:ChartDataLabelsType" minOccurs="0"/>
<xs:element name="chartSeriesList" type="sml:ChartSeriesListType" minOccurs="0"/>

View File

@@ -129,6 +129,15 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
- `<a>`
- `<shadow>`
- `<outline>`
- `<formula>`
公式写法:
```xml
<p>公式:<formula><latex><![CDATA[ E = mc^2 ]]></latex></formula></p>
```
`<formula>` 是内联元素;当前只支持一个 `<latex>` 子元素。LaTeX 内容必须放在 `CDATA` 中,且 `CDATA` 内不要写 XML 转义;宏只使用服务端支持范围内的写法,优先用基础运算符、`\frac``\sqrt``matrix`
示例:
@@ -312,6 +321,36 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
`<chart>` 直接子元素必须有 `<chartPlotArea>`(绘图区)和 `<chartData>`(数据);`<chartTitle>``<chartSubTitle>``<chartStyle>``<chartLegend>``<chartTooltip>` 可选,如果想不展示标题、副标题、图例或悬浮提示,省略相应元素标签即可。
`<chartStyle>` 常用子元素:
- `<chartBackground>``color` 省略时由渲染端决定默认背景;需要完全透明请显式写 `color="rgba(0, 0, 0, 0)"`
- `<chartBorder>`:无边框可写 `width="0"`,或直接不写 `<chartBorder>` 元素
#### 图表渐变 `<fillGradient>` / `<strokeGradient>`
图表支持渐变填充/描边,`<fillGradient>` 用于面积、柱子、数据点、扇区填充,`<strokeGradient>` 用于线条、数据点边框、柱子边框。渐变只能挂在系列级或单元素级,不要挂在 `<chartPlot>` 全局层。
可挂载位置:
- 系列级:`<chartBars>` / `<chartPoints>` 支持 `<fillGradient>``<strokeGradient>``<chartLine>` 只支持 `<strokeGradient>``<chartArea>` / `<chartSectors>` 只支持 `<fillGradient>`
- 单元素级:`<chartBar index="...">` / `<chartPoint index="...">` / `<chartSector index="...">` 只支持 `<fillGradient>`
- 全局级:`<chartPlot>` 下的 `<chartLines>` / `<chartAreas>` / `<chartBars>` / `<chartPoints>` 不支持渐变
结构要点:`type` 必填,可为 `linear``radial``linear``x0` / `y0` / `x1` / `y1``radial``r0` / `r1``<stops>` 至少包含 2 个 `<stop>``offset``opacity` 取值均为 `[0, 1]`
```xml
<chartSeries index="1">
<chartBars>
<fillGradient type="linear" x0="0" y0="0" x1="0" y1="1">
<stops>
<stop offset="0" color="rgb(28, 71, 120)"/>
<stop offset="1" color="rgb(28, 71, 120)" opacity="0.3"/>
</stops>
</fillGradient>
</chartBars>
</chartSeries>
```
隐藏 `<chart>` 的图例只能通过不写或删除 `<chartLegend>` 实现,`<chartLegend>` 不支持 `position="none"`
详细用法见 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)。

View File

@@ -49,6 +49,17 @@ ROUNDTRIP_SXSD_ATTRS = {
ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"}
DEFAULT_TABLE_COLUMN_WIDTH = 110
DEFAULT_TABLE_ROW_HEIGHT = 37
DEFAULT_TEXT_LINE_SPACING_MULTIPLE = 1.5
TEXT_WRAP_WIDTH_TOLERANCE_PX = 1.0
TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX = 0.5
SINGLE_LINE_METRIC_WIDTH_RATIO = 1.18
CENTERED_SHORT_LABEL_WIDTH_RATIO = 1.12
HEADLINE_NEAR_FIT_WIDTH_RATIO = 1.04
DENSE_BODY_LINE_SPACING_MAX_MULTIPLE = 1.6
GHOST_TEXT_MIN_FONT_SIZE = 96
GHOST_TEXT_MAX_ALPHA = 0.5
GHOST_TEXT_FAINT_MIN_FONT_SIZE = 36
GHOST_TEXT_FAINT_MAX_ALPHA = 0.35
# Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
# visible defect; keep this well under 1px so real overflow is still always caught.
CANVAS_OVERFLOW_TOLERANCE = 0.5
@@ -106,6 +117,52 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
return int(value) if value.is_integer() else value
def extract_bool_attribute(tag_source: str, name: str) -> bool:
value = extract_attribute(tag_source, name)
return value in {"true", "1", "yes"}
def extract_color_alpha(color: str | None) -> int | float | None:
if color is None:
return None
normalized = re.sub(r"\s+", "", color).lower()
if normalized == "transparent":
return 0
rgba_match = re.fullmatch(
r"rgba\([^,]+,[^,]+,[^,]+,([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\)",
normalized,
)
if rgba_match is None:
return None
try:
alpha = float(rgba_match.group(1))
except ValueError:
return None
return int(alpha) if alpha.is_integer() else alpha
def effective_text_alpha(shape_alpha: int | float | None, text_color: str | None) -> int | float:
base_alpha = shape_alpha if isinstance(shape_alpha, (int, float)) else 1
color_alpha = extract_color_alpha(text_color)
if not isinstance(color_alpha, (int, float)):
return base_alpha
return base_alpha * color_alpha
def detect_inline_style_presence(content_xml: str, style_tags: set[str]) -> bool:
for tag_name in style_tags:
if re.search(fr"<{re.escape(tag_name)}\b[\s>]", content_xml) is not None:
return True
return False
def detect_any_span_bool_attribute(content_xml: str, attr_name: str) -> bool:
for attrs in re.findall(r"<span\b([^>]*)>", content_xml):
if extract_bool_attribute(attrs, attr_name):
return True
return False
def sum_sizes(sizes: list[int | float]) -> int | float:
return sum(sizes)
@@ -218,6 +275,7 @@ def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[
"lineSpacing": extract_attribute(attrs, "lineSpacing"),
"beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
"afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
"letterSpacing": extract_numeric_attribute(attrs, "letterSpacing"),
}
)
return paragraphs
@@ -694,6 +752,20 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
font_size = extract_numeric_attribute(content_attrs, "fontSize")
if font_size is None:
font_size = extract_numeric_attribute(attrs, "fontSize")
font_family = extract_attribute(content_attrs, "fontFamily") or extract_attribute(attrs, "fontFamily")
text_color = extract_attribute(content_attrs, "color") or extract_attribute(attrs, "color")
bold = (
extract_bool_attribute(content_attrs, "bold")
or extract_bool_attribute(attrs, "bold")
or detect_inline_style_presence(content, {"strong", "b"})
or detect_any_span_bool_attribute(content, "bold")
)
italic = (
extract_bool_attribute(content_attrs, "italic")
or extract_bool_attribute(attrs, "italic")
or detect_inline_style_presence(content, {"i", "em"})
or detect_any_span_bool_attribute(content, "italic")
)
element.update(
{
"textType": extract_attribute(content_attrs, "textType"),
@@ -705,11 +777,17 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
"lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
"beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
"afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
"letterSpacing": extract_numeric_attribute(content_attrs, "letterSpacing"),
"paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
"paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
"paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
"paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
"fontSize": font_size if font_size is not None else 16,
"fontFamily": font_family or "",
"color": text_color,
"textAlpha": effective_text_alpha(alpha, text_color),
"bold": bold,
"italic": italic,
"text": strip_xml_paragraphs(content),
"paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
}
@@ -745,7 +823,11 @@ def is_vertical_text(element: dict[str, Any]) -> bool:
def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
text_elements = [element for element in elements if is_text_element(element) and has_text_content(element)]
text_elements = [
element
for element in elements
if is_text_element(element) and has_text_content(element) and not is_ghost_text(element)
]
image_elements = [element for element in elements if element["kind"] == "img" and element["alpha"] > 0]
for text_element in text_elements:
for image_element in image_elements:
@@ -782,22 +864,135 @@ def normalize_text_for_overlap(text: str) -> str:
return re.sub(r"\s+", "", text)
def estimate_character_width(character: str, font_size: int | float) -> int | float:
SERIF_FONT_PATTERNS = {
"song", "songti", "simsun", "ming", "mincho",
"georgia", "times", "caslon", "garamond", "sourcehan-serif",
"source han serif", "思源宋体", "宋体", "明体",
}
SANS_EXPLICIT_MARKERS = {"sans", "sans-serif", "sans serif", "sourcehan-sans", "source han sans", "思源黑体", "黑体",
"helvetica", "arial", "inter", "roboto", "verdana", "tahoma", "calibri", "open sans"}
def classify_font_family(font_family: str | None) -> str:
if not font_family:
return "sans"
family_lower = font_family.lower()
for marker in SANS_EXPLICIT_MARKERS:
if marker in family_lower:
return "sans"
serif_keywords = SERIF_FONT_PATTERNS | {"serif"}
for pattern in serif_keywords:
if pattern in family_lower:
return "serif"
return "sans"
_FONT_CATEGORY_MULTIPLIERS: dict[str, dict[str, float]] = {
"sans": {"upper": 0.57, "lower": 0.51, "digit": 0.58, "punct": 0.50},
"serif": {"upper": 0.57, "lower": 0.53, "digit": 0.58, "punct": 0.50},
}
def estimate_character_width(
character: str,
font_size: int | float,
bold: bool = False,
font_family: str | None = None,
) -> int | float:
bold_multiplier = 1.05 if bold else 1.0
if character.isspace():
return font_size * 0.33
if unicodedata.east_asian_width(character) in {"F", "W"}:
return font_size
return font_size * 0.55
return font_size * 0.33 * bold_multiplier
ea_width = unicodedata.east_asian_width(character)
if ea_width in {"F", "W"}:
return font_size * bold_multiplier
category = classify_font_family(font_family)
coeffs = _FONT_CATEGORY_MULTIPLIERS[category]
if character.isupper():
return font_size * coeffs["upper"] * bold_multiplier
if character.islower():
return font_size * coeffs["lower"] * bold_multiplier
if character.isdigit():
return font_size * coeffs["digit"] * bold_multiplier
return font_size * coeffs["punct"] * bold_multiplier
def estimate_text_width(text: str, font_size: int | float) -> int | float:
return sum(estimate_character_width(character, font_size) for character in text)
def estimate_text_width(
text: str,
font_size: int | float,
letter_spacing: int | float = 0,
bold: bool = False,
font_family: str | None = None,
) -> int | float:
base = sum(estimate_character_width(character, font_size, bold, font_family) for character in text)
return base + max(len(text) - 1, 0) * letter_spacing
def resolve_letter_spacing(element: dict[str, Any], paragraph: dict[str, Any] | None = None) -> int | float:
if paragraph is not None:
value = paragraph.get("letterSpacing")
if isinstance(value, (int, float)):
return value
value = element.get("letterSpacing")
return value if isinstance(value, (int, float)) else 0
def text_wrap_width_tolerance() -> int | float:
return TEXT_WRAP_WIDTH_TOLERANCE_PX
def text_height_overflow_tolerance() -> int | float:
return TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX
def has_explicit_height_auto_fit(element: dict[str, Any]) -> bool:
return element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}
def is_short_metric_text(text: str) -> bool:
compact = re.sub(r"\s+", "", text)
if not compact or len(compact) > 16 or re.search(r"\d", compact) is None:
return False
if re.fullmatch(r"[+\-–—]?[0-9,.]+[\u4e00-\u9fffA-Za-z]{1,4}", compact):
return True
if re.search(r"[,.+\-–—/%]", compact) is None:
return False
return re.fullmatch(r"[+\-–—]?[0-9A-Za-z,./%\-–—\u4e00-\u9fff]+", compact) is not None
def is_single_line_visual_candidate(
element: dict[str, Any],
paragraph: dict[str, Any] | None,
text: str,
logical_width: int | float,
effective_width: int | float,
) -> bool:
if "\n" in text or logical_width <= effective_width:
return False
if is_short_metric_text(text):
return logical_width <= effective_width * SINGLE_LINE_METRIC_WIDTH_RATIO
text_align = (paragraph or {}).get("textAlign") or element.get("textAlign")
compact_len = len(re.sub(r"\s+", "", text))
if text_align == "center" and compact_len <= 32:
return logical_width <= effective_width * CENTERED_SHORT_LABEL_WIDTH_RATIO
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
if element.get("textType") in {"headline", "title"} and font_size <= 30 and compact_len <= 40:
return logical_width <= effective_width * HEADLINE_NEAR_FIT_WIDTH_RATIO
return False
def estimate_text_max_line_width(element: dict[str, Any]) -> int | float:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
bold = element.get("bold", False)
font_family = element.get("fontFamily", "")
letter_spacing = resolve_letter_spacing(element)
paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
return max([estimate_text_width(paragraph, font_size) for paragraph in paragraphs] or [1])
return max(
[estimate_text_width(paragraph, font_size, letter_spacing, bold, font_family) for paragraph in paragraphs]
or [1]
)
def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
@@ -810,8 +1005,14 @@ def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool
return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int:
def estimate_text_line_count_for_text(
element: dict[str, Any], text: str, paragraph: dict[str, Any] | None = None
) -> int:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
bold = element.get("bold", False)
font_family = element.get("fontFamily", "")
letter_spacing = resolve_letter_spacing(element, paragraph)
available_width = max(element["width"] - element.get("paddingLeft", 0) - element.get("paddingRight", 0), 1)
hard_lines = text.split("\n")
if not text:
return 0
@@ -820,8 +1021,12 @@ def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int
if element.get("wrap") in {"false", "0"}:
line_count += 1
continue
logical_width = max(estimate_text_width(hard_line, font_size), 1)
line_count += max(1, math.ceil(logical_width / max(element["width"], 1)))
logical_width = max(estimate_text_width(hard_line, font_size, letter_spacing, bold, font_family), 1)
effective_width = available_width + text_wrap_width_tolerance()
if is_single_line_visual_candidate(element, paragraph, hard_line, logical_width, effective_width):
line_count += 1
continue
line_count += max(1, math.ceil(logical_width / effective_width))
return line_count
@@ -831,7 +1036,8 @@ def estimate_text_line_count(element: dict[str, Any]) -> int:
def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
line_spacing = line_spacing or "multiple:1.5"
if line_spacing is None:
return font_size * DEFAULT_TEXT_LINE_SPACING_MULTIPLE
match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
if match is None:
return None
@@ -839,12 +1045,27 @@ def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None
return font_size * float(value) if spacing_type == "multiple" else float(value)
def adjust_dense_body_line_height(
element: dict[str, Any],
line_spacing: str | None,
line_height: int | float,
paragraph_count: int,
) -> int | float:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
if paragraph_count < 4 or font_size > 14 or not line_spacing:
return line_height
match = re.fullmatch(r"multiple:([0-9]+(?:\.[0-9]+)?)", line_spacing)
if match is None:
return line_height
return min(line_height, font_size * min(float(match.group(1)), DENSE_BODY_LINE_SPACING_MAX_MULTIPLE))
def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in elements:
if not is_text_element(element) or not has_text_content(element):
continue
if element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}:
if has_explicit_height_auto_fit(element):
continue
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
@@ -860,10 +1081,11 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
estimated_height = 0.0
line_heights: list[int | float] = []
for paragraph in paragraphs:
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"])
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"], paragraph)
if paragraph_line_count == 0:
continue
line_height = estimate_text_line_height(element, paragraph["lineSpacing"] or element["lineSpacing"])
resolved_line_spacing = paragraph["lineSpacing"] or element["lineSpacing"]
line_height = estimate_text_line_height(element, resolved_line_spacing)
before_spacing = estimate_text_line_height(
element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
)
@@ -873,6 +1095,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
if line_height is None or before_spacing is None or after_spacing is None:
line_count = 0
break
line_height = adjust_dense_body_line_height(element, resolved_line_spacing, line_height, len(paragraphs))
first_line_height = font_size if line_count == 0 else line_height
line_count += paragraph_line_count
line_heights.append(line_height)
@@ -883,12 +1106,24 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
continue
available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0)
overflow = estimated_height - available_height
if overflow <= 0:
if overflow <= text_height_overflow_tolerance():
continue
is_background = is_background_decorative_text(element, elements)
if is_background:
level = "info"
else:
level = "error" if overflow > 10 else "warning"
message = (
f'text shape {element["id"]} may overflow its own content box '
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
'consider setting content wrap="true" autoFit="normal-auto-fit"'
)
if is_background:
message += " (likely background decoration: large font, low alpha, underneath other text)"
issues.append(
{
"level": "warning",
"level": level,
"code": "text_may_overflow_shape",
"elements": [element["id"]],
"line_count": line_count,
@@ -896,11 +1131,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
"estimated_height": estimated_height,
"available_height": available_height,
"overflow": overflow,
"message": (
f'text shape {element["id"]} may overflow its own content box '
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
'consider setting content wrap="true" autoFit="normal-auto-fit"'
),
"message": message,
"hint": (
"Increase shape.height, reduce the text, or set content wrap=\"true\" "
"autoFit=\"normal-auto-fit\". "
@@ -911,6 +1142,38 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
return issues
def is_background_decorative_text(
element: dict[str, Any], elements: list[dict[str, Any]]
) -> bool:
if not is_ghost_text(element):
return False
for other in elements:
if other is element:
continue
if not is_text_element(other) or not has_text_content(other):
continue
foreground_alpha = other.get("textAlpha", other.get("alpha", 1))
if not isinstance(foreground_alpha, (int, float)) or foreground_alpha <= 0:
continue
if other["order"] <= element["order"]:
continue
if intersects(element, other):
return True
return False
def is_ghost_text(element: dict[str, Any]) -> bool:
if not is_text_element(element) or not has_text_content(element):
return False
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
text_alpha = element.get("textAlpha", element.get("alpha", 1))
if not isinstance(text_alpha, (int, float)):
return False
if font_size > GHOST_TEXT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_MAX_ALPHA:
return True
return font_size >= GHOST_TEXT_FAINT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_FAINT_MAX_ALPHA
def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
return None
@@ -1022,6 +1285,8 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
return False
if not (has_text_content(left) and has_text_content(right)):
return False
if is_ghost_text(left) or is_ghost_text(right):
return False
if is_template_text_stack(left, right) or is_similar_text_overlay(left, right):
return False
@@ -1038,13 +1303,16 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
return False
font_size = source["fontSize"] if isinstance(source["fontSize"], (int, float)) else 16
padding_left = source.get("paddingLeft", 0)
padding_right = source.get("paddingRight", 0)
available_width = max(source["width"] - padding_left - padding_right, 1)
visual_width = estimate_text_max_line_width(source)
overflow_width = visual_width - source["width"]
min_overflow = max(font_size * 1.5, source["width"] * 0.08)
overflow_width = visual_width - available_width
min_overflow = max(font_size * 1.5, available_width * 0.08)
if overflow_width < min_overflow:
return False
intrusion_width = source["x"] + visual_width - target["x"]
intrusion_width = source["x"] + padding_left + visual_width - target["x"]
min_intrusion = max(font_size * 1.5, target["width"] * 0.08)
if intrusion_width < min_intrusion:
return False
@@ -1056,8 +1324,9 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
source, target = sorted([left, right], key=lambda element: element["x"])
padding_left = source.get("paddingLeft", 0)
visual_width = estimate_text_max_line_width(source)
source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]}
source_visual_bbox = {"x": source["x"] + padding_left, "y": source["y"], "width": visual_width, "height": source["height"]}
width = intersection_width(source_visual_bbox, target)
height = intersection_height(source_visual_bbox, target)
return {
@@ -1072,6 +1341,8 @@ def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
return False
if is_text_element(right) and not has_text_content(right):
return False
if is_ghost_text(left) or is_ghost_text(right):
return False
if is_template_text_stack(left, right):
return False
if is_text_element(left) and is_text_element(right):
@@ -1118,6 +1389,8 @@ def should_report_whiteboard_overlap(
) -> dict[str, Any] | None:
if other is whiteboard or not intersects(whiteboard, other):
return None
if is_ghost_text(other):
return None
if contains(whiteboard, other):
return None
if is_bottom_layer_full_slide_whiteboard(whiteboard, other, slide_width, slide_height):
@@ -1194,6 +1467,8 @@ def detect_whiteboard_external_overlaps(
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
return bbox
rotation = element["rotation"]
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
rotation = 0
@@ -1219,7 +1494,14 @@ def detect_elements_out_of_canvas(
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in elements:
for element in (
element
for element in elements
if element["kind"] in {"table", "chart"}
or (element["kind"] == "shape" and element["type"] in {"rect", "text"})
):
if is_ghost_text(element):
continue
bbox = element_canvas_bbox(element)
overflow = {
"left": max(-bbox["x"], 0),
@@ -1976,8 +2258,6 @@ def normalize_issue(
elements_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
normalized = dict(issue)
if normalized.get("level") == "info":
normalized["level"] = "warning"
element_ids = list(dict.fromkeys(normalized.get("elements", [])))
normalized["schema_version"] = "2.0"
normalized["element_ids"] = element_ids
@@ -2039,8 +2319,10 @@ def build_result(
) -> dict[str, Any]:
document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
document_infos = [issue for issue in top_level_issues if issue["level"] == "info"]
error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
info_count = len(document_infos) + sum(len(slide["infos"]) for slide in slides)
all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
status = slide_status(all_errors, all_warnings)
@@ -2053,6 +2335,7 @@ def build_result(
"slide_count": len(slides),
"error_count": error_count,
"warning_count": warning_count,
"info_count": info_count,
"status": status,
"release_ready": error_count == 0,
"screenshot_review_required": warning_count > 0,
@@ -2060,6 +2343,7 @@ def build_result(
"document": {
"errors": document_errors,
"warnings": document_warnings,
"infos": document_infos,
},
"slides": slides,
}
@@ -2150,6 +2434,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
]
errors = [issue for issue in issues if issue["level"] == "error"]
warnings = [issue for issue in issues if issue["level"] == "warning"]
infos = [issue for issue in issues if issue["level"] == "info"]
slides.append(
{
"slide_number": slide_number,
@@ -2157,6 +2442,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
"element_count": len(elements_by_id),
"errors": errors,
"warnings": warnings,
"infos": infos,
"issues": issues,
}
)

View File

@@ -343,6 +343,26 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("issues", result)
def test_lint_xml_ignores_chart_parsed_values_roundtrip_tag(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<chart topLeftX="80" topLeftY="80" width="300" height="160">
<chartData>
<chartField>
<chartParsedValues>Africa</chartParsedValues>
</chartField>
</chartData>
</chart>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("issues", result)
def test_lint_xml_limits_chart_roundtrip_attrs_to_matching_tags(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -637,9 +657,10 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "text_may_overflow_shape")
self.assertEqual(result["slides"][0]["issues"][0]["level"], "error")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source"])
def test_lint_xml_reports_text_out_of_canvas_and_warns_for_text_height(self) -> None:
@@ -660,8 +681,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(issue["code"], "shape_out_of_canvas")
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40})
@@ -671,7 +692,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="overflowing" type="text" topLeftX="80" topLeftY="80" width="360" height="80">
<content fontSize="20" lineSpacing="multiple:1.5">
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit">
<p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
</content>
</shape>
@@ -685,20 +706,29 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
<p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
</content>
</shape>
<shape id="shape-auto-fit" type="text" topLeftX="480" topLeftY="240" width="360" height="30">
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="shape-auto-fit">
<p>第一段</p><p>第二段</p><p>第三段</p>
</content>
</shape>
</data>
</slide>
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(issues[0]["code"], "text_may_overflow_shape")
self.assertEqual(issues[0]["elements"], ["overflowing"])
self.assertEqual(issues[0]["line_count"], 4)
self.assertEqual(issues[0]["estimated_height"], 110)
self.assertEqual(issues[0]["available_height"], 80)
self.assertEqual(issues[0]["overflow"], 30)
self.assertIn('wrap="true" autoFit="normal-auto-fit"', issues[0]["message"])
overflow_issues = [issue for issue in issues if issue["code"] == "text_may_overflow_shape"]
self.assertEqual(result["summary"]["error_count"], 1)
overflow_ids = {issue["elements"][0] for issue in overflow_issues}
self.assertIn("overflowing", overflow_ids)
self.assertNotIn("auto-fit", overflow_ids)
self.assertNotIn("shape-auto-fit", overflow_ids)
self.assertNotIn("fitting", overflow_ids)
overflowing_issue = next(issue for issue in overflow_issues if issue["elements"] == ["overflowing"])
self.assertEqual(overflowing_issue["line_count"], 4)
self.assertEqual(overflowing_issue["estimated_height"], 110)
self.assertEqual(overflowing_issue["available_height"], 80)
self.assertEqual(overflowing_issue["overflow"], 30)
self.assertIn('wrap="true" autoFit="normal-auto-fit"', overflowing_issue["message"])
def test_lint_xml_uses_fixed_line_spacing_for_text_height_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -706,7 +736,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="fixed-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
<content fontSize="20" lineSpacing="fixed:20">
<content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
<p>第一段</p><p>第二段</p><p>第三段</p>
</content>
</shape>
@@ -716,17 +746,525 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(issue["level"], "warning")
self.assertEqual(issue["line_height"], 20)
self.assertEqual(issue["estimated_height"], 60)
self.assertEqual(issue["overflow"], 10)
def test_lint_xml_ignores_subpixel_text_height_overflow_tolerance(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="minor-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="39.8">
<content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
<p>第一段</p><p>第二段</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_allows_single_line_width_estimation_jitter(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="metric" type="text" topLeftX="80" topLeftY="80" width="152" height="54">
<content fontSize="36" lineSpacing="multiple:1.2" autoFit="no-auto-fit"><p>4.16万亿</p></content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_allows_short_metric_text_with_separators_as_single_line(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="metric" type="text" topLeftX="80" topLeftY="80" width="150" height="50">
<content textType="title" fontSize="36" autoFit="no-auto-fit"><p>4.16万亿</p></content>
</shape>
<shape id="table-number" type="text" topLeftX="80" topLeftY="160" width="25" height="20">
<content fontSize="10" textAlign="center" autoFit="no-auto-fit"><p>1,380</p></content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_reports_plain_short_metric_when_it_wraps(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="plain-age" type="text" topLeftX="80" topLeftY="80" width="50" height="80">
<content textType="title" fontSize="36" bold="true" autoFit="no-auto-fit"><p>82岁</p></content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["plain-age"])
def test_lint_xml_allows_centered_short_label_near_fit_as_single_line(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="centered-label" type="text" topLeftX="80" topLeftY="80" width="200" height="30">
<content fontSize="14" bold="true" textAlign="center" autoFit="no-auto-fit">
<p>参数服务器 (Parameter Server)</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_allows_headline_near_fit_as_single_line(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="headline" type="text" topLeftX="80" topLeftY="80" width="700" height="50">
<content textType="headline" fontSize="26" bold="true" lineSpacing="multiple:1.3" autoFit="no-auto-fit">
<p>全球半导体市场规模持续高速增长AI驱动新一轮景气周期</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_allows_dense_body_line_spacing_estimation_slack(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="dense-body" type="text" topLeftX="80" topLeftY="80" width="360" height="140">
<content fontSize="13" bold="true" lineSpacing="multiple:1.7" autoFit="no-auto-fit">
<p>总体目标:</p>
<p>建立深度神经网络高效训练的统一理论框架,实现训练效率与模型性能的协同优化。</p>
<p>具体目标:</p>
<p>提出自适应优化算法,收敛速度提升 2-3 倍</p>
<p>实现结构化压缩方法,模型体积减少 10 倍以上</p>
<p>构建分布式训练策略64 GPU 加速比 &gt; 50x</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_reports_dense_body_when_adjusted_height_still_overflows(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="dense-body" type="text" topLeftX="80" topLeftY="80" width="360" height="100">
<content fontSize="13" bold="true" lineSpacing="multiple:1.7" autoFit="no-auto-fit">
<p>总体目标:</p>
<p>建立深度神经网络高效训练的统一理论框架,实现训练效率与模型性能的协同优化。</p>
<p>具体目标:</p>
<p>提出自适应优化算法,收敛速度提升 2-3 倍</p>
<p>实现结构化压缩方法,模型体积减少 10 倍以上</p>
<p>构建分布式训练策略64 GPU 加速比 &gt; 50x</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["dense-body"])
def test_lint_xml_reports_letter_spaced_caption_near_fit(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="caption" type="text" topLeftX="80" topLeftY="80" width="120" height="20">
<content textType="caption" fontSize="11" letterSpacing="1" autoFit="no-auto-fit">
<p>RISKS &amp; CHALLENGES</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["caption"])
def test_lint_xml_reports_micro_caption_when_wrapping_overflows(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="micro-caption" type="text" topLeftX="80" topLeftY="60" width="200" height="16">
<content textType="caption" fontSize="3" lineSpacing="multiple:1.3" letterSpacing="160" autoFit="no-auto-fit">
<p>MARKET INSIGHT · 市场洞察</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["micro-caption"])
def test_lint_xml_text_may_overflow_shape_upgrades_to_error_above_threshold(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="just-warning" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
<content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
<p>第一段</p><p>第二段</p><p>第三段</p>
</content>
</shape>
<shape id="error-overflow" type="text" topLeftX="80" topLeftY="200" width="360" height="30">
<content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
<p>第一段</p><p>第二段</p><p>第三段</p>
</content>
</shape>
</data>
</slide>
"""
)
issues = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(issues["just-warning"]["level"], "warning")
self.assertEqual(issues["just-warning"]["overflow"], 10)
self.assertEqual(issues["error-overflow"]["level"], "error")
self.assertEqual(issues["error-overflow"]["overflow"], 30)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 1)
def test_lint_xml_text_may_overflow_shape_downgrades_background_decoration_to_info(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="bg-deco" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
</shape>
<shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
</shape>
</data>
</slide>
"""
)
issues = {
issue["elements"][0]: issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
}
self.assertEqual(issues["bg-deco"]["level"], "info")
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], 1)
self.assertEqual(result["slides"][0]["infos"], [issues["bg-deco"]])
self.assertIn("background decoration", issues["bg-deco"]["message"])
def test_lint_xml_allows_shape_alpha_ghost_text_out_of_canvas_and_overlap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="ghost-number" type="text" topLeftX="-60" topLeftY="30" width="360" height="180" alpha="0.2">
<content fontSize="160" lineSpacing="fixed:160" wrap="false"><p>01</p></content>
</shape>
<shape id="title" type="text" topLeftX="80" topLeftY="80" width="360" height="80">
<content fontSize="30" lineSpacing="fixed:36"><p>Annual Review</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("shape_out_of_canvas", codes)
self.assertNotIn("bbox_overlap", codes)
def test_lint_xml_allows_content_color_alpha_ghost_text_out_of_canvas_and_overlap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="ghost-year" type="text" topLeftX="760" topLeftY="20" width="260" height="160">
<content fontSize="140" color="rgba(0,0,0,0.2)" lineSpacing="fixed:140" wrap="false"><p>2026</p></content>
</shape>
<shape id="headline" type="text" topLeftX="700" topLeftY="70" width="220" height="80">
<content fontSize="28" lineSpacing="fixed:34"><p>Forecast</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("shape_out_of_canvas", codes)
self.assertNotIn("bbox_overlap", codes)
def test_lint_xml_allows_faint_medium_ghost_text_out_of_canvas_and_overlap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="medium-ghost" type="text" topLeftX="820" topLeftY="300" width="270" height="72" alpha="0.32">
<content fontSize="40" lineSpacing="fixed:40" wrap="false"><p>OFF EDGE</p></content>
</shape>
<shape id="caption" type="text" topLeftX="760" topLeftY="315" width="180" height="36">
<content fontSize="16" lineSpacing="fixed:20"><p>Readable caption</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("shape_out_of_canvas", codes)
self.assertNotIn("bbox_overlap", codes)
def test_lint_xml_allows_ghost_text_image_overlap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="ghost-label" type="text" topLeftX="100" topLeftY="40" width="560" height="160" alpha="0.2">
<content fontSize="120" lineSpacing="fixed:120" wrap="false"><p>2026</p></content>
</shape>
<img id="photo" src="token" topLeftX="160" topLeftY="70" width="260" height="160"/>
<shape id="title" type="text" topLeftX="610" topLeftY="95" width="320" height="60">
<content fontSize="28" lineSpacing="fixed:34"><p>Annual Review</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("image_covers_text", codes)
self.assertNotIn("bbox_overlap", codes)
def test_lint_slide_allows_ghost_text_whiteboard_overlap(self) -> None:
result = xml_text_overlap_lint.lint_slide(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<whiteboard id="board" topLeftX="180" topLeftY="70" width="420" height="300"/>
<shape id="ghost-label" type="text" topLeftX="100" topLeftY="40" width="560" height="160" alpha="0.2">
<content fontSize="120" lineSpacing="fixed:120" wrap="false"><p>2026</p></content>
</shape>
<shape id="title" type="text" topLeftX="610" topLeftY="95" width="220" height="60">
<content fontSize="28" lineSpacing="fixed:34"><p>Annual Review</p></content>
</shape>
</data>
</slide>
""",
1,
)
codes = [issue["code"] for issue in result["issues"]]
self.assertNotIn("whiteboard_external_overlap", codes)
def test_lint_xml_allows_faint_ghost_text_without_area_threshold(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="small-ghost" type="text" topLeftX="940" topLeftY="300" width="40" height="40" alpha="0.32">
<content fontSize="36" lineSpacing="fixed:36" wrap="false"><p>土</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_keeps_out_of_canvas_error_for_medium_text_without_faint_alpha(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="medium-not-ghost" type="text" topLeftX="820" topLeftY="300" width="270" height="72" alpha="0.36">
<content fontSize="54" lineSpacing="fixed:54" wrap="false"><p>OFF EDGE</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["medium-not-ghost"])
def test_lint_xml_keeps_out_of_canvas_error_for_half_alpha_large_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="half-alpha" type="text" topLeftX="760" topLeftY="20" width="260" height="160">
<content fontSize="140" color="rgba(0,0,0,0.5)" lineSpacing="fixed:140" wrap="false"><p>2026</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["half-alpha"])
def test_lint_xml_text_may_overflow_shape_keeps_error_when_alpha_not_low(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="opaque-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.9">
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
</shape>
<shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
</shape>
</data>
</slide>
"""
)
issue = next(
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["opaque-big"]
)
self.assertEqual(issue["level"], "error")
def test_lint_xml_text_may_overflow_shape_keeps_error_when_no_foreground_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="lonely-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
</shape>
</data>
</slide>
"""
)
issue = next(
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["lonely-big"]
)
self.assertEqual(issue["level"], "error")
def test_lint_xml_text_may_overflow_shape_keeps_error_when_foreground_alpha_zero(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="bg-deco" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
</shape>
<shape id="transparent-foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60" alpha="0">
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
</shape>
</data>
</slide>
"""
)
issue = next(
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["bg-deco"]
)
self.assertEqual(issue["level"], "error")
def test_lint_xml_text_may_overflow_shape_keeps_error_when_foreground_is_below_in_order(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
</shape>
<shape id="top-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
</shape>
</data>
</slide>
"""
)
issue = next(
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["top-big"]
)
self.assertEqual(issue["level"], "error")
def test_lint_xml_uses_paragraph_spacing_overrides_for_text_height_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="paragraph-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="35">
<content fontSize="20" lineSpacing="multiple:1.5">
<shape id="paragraph-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="30">
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit">
<p lineSpacing="fixed:10" beforeLineSpacing="fixed:5" afterLineSpacing="fixed:5">第一行<br/>第二行</p>
</content>
</shape>
@@ -745,7 +1283,36 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(issues[0]["line_count"], 2)
self.assertEqual(issues[0]["line_height"], 10)
self.assertEqual(issues[0]["estimated_height"], 40)
self.assertEqual(issues[0]["overflow"], 5)
self.assertEqual(issues[0]["overflow"], 10)
def test_lint_xml_uses_letter_spacing_for_text_overflow_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="baseline" type="text" topLeftX="0" topLeftY="0" width="120" height="30">
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit"><p>一二三四五六</p></content>
</shape>
<shape id="content-spaced" type="text" topLeftX="200" topLeftY="0" width="120" height="30">
<content fontSize="20" lineSpacing="multiple:1.5" letterSpacing="2" autoFit="no-auto-fit"><p>一二三四五六</p></content>
</shape>
<shape id="paragraph-spaced" type="text" topLeftX="400" topLeftY="0" width="120" height="30">
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit"><p letterSpacing="2">一二三四五六</p></content>
</shape>
</data>
</slide>
"""
)
issues = result["slides"][0]["issues"]
overflow_ids = [issue["elements"][0] for issue in issues if issue["code"] == "text_may_overflow_shape"]
self.assertNotIn("baseline", overflow_ids)
self.assertIn("content-spaced", overflow_ids)
self.assertIn("paragraph-spaced", overflow_ids)
by_id = {issue["elements"][0]: issue for issue in issues if issue["code"] == "text_may_overflow_shape"}
self.assertEqual(by_id["content-spaced"]["line_count"], 2)
self.assertEqual(by_id["content-spaced"]["estimated_height"], 50)
self.assertEqual(by_id["content-spaced"]["overflow"], 20)
self.assertEqual(by_id["paragraph-spaced"]["line_count"], 2)
def test_strip_xml_paragraphs_preserves_br_as_hard_line_break(self) -> None:
self.assertEqual(
@@ -753,7 +1320,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"第一行\n第二行\n第三行",
)
def test_lint_xml_blocks_template_style_bleed_outside_canvas(self) -> None:
def test_lint_xml_allows_template_style_images_outside_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -771,9 +1338,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
def test_extract_elements_preserves_supported_element_geometry_order_and_text_metadata(self) -> None:
elements = xml_text_overlap_lint.extract_elements(
@@ -807,7 +1373,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(elements[1]["fontSize"], 28)
self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
def test_lint_xml_blocks_small_out_of_bounds_images(self) -> None:
def test_lint_xml_ignores_small_out_of_bounds_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -819,10 +1385,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_blocks_out_of_canvas_images(self) -> None:
def test_lint_xml_ignores_out_of_canvas_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -835,13 +1400,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(
[issue["code"] for issue in result["slides"][0]["errors"]],
["img_out_of_canvas", "img_out_of_canvas"],
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_blocks_full_bleed_images_outside_canvas(self) -> None:
def test_lint_xml_ignores_full_bleed_images_outside_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -853,10 +1414,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None:
def test_lint_xml_reports_text_and_chart_but_not_image_out_of_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -871,17 +1431,16 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["error_count"], 3)
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(
[(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
[
("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
("img_out_of_canvas", ["outside-img"], {"left": 0, "top": 20, "right": 0, "bottom": 0}),
("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
],
)
def test_lint_xml_reports_line_out_of_canvas_with_structured_geometry(self) -> None:
def test_lint_xml_ignores_line_out_of_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
@@ -895,11 +1454,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issue = result["slides"][0]["errors"][0]
self.assertEqual(issue["code"], "line_out_of_canvas")
self.assertEqual(issue["element_ids"], ["connector"])
self.assertEqual(issue["measurement"]["overflow"]["right"], 20)
self.assertEqual(issue["related_objects"][0]["kind"], "line")
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -922,13 +1478,13 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)
def test_lint_xml_uses_rotated_bounds_for_rect_and_image_canvas_validation(self) -> None:
def test_lint_xml_uses_declared_bounds_for_rect_and_ignores_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="rotated-rect" type="rect" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
<shape id="rotated-rect" type="rect" topLeftX="900" topLeftY="0" width="100" height="100" rotation="45"/>
<img id="rotated-image" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
</data>
</slide>
@@ -936,12 +1492,54 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(issues_by_element["rotated-rect"]["code"], "shape_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["left"], 20.710678, places=5)
self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["top"], 20.710678, places=5)
self.assertEqual(issues_by_element["rotated-image"]["code"], "img_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-image"]["overflow"]["right"], 20.710678, places=5)
self.assertEqual(issues_by_element["rotated-rect"]["overflow"], {"left": 0, "top": 0, "right": 40, "bottom": 0})
self.assertNotIn("rotated-image", issues_by_element)
def test_detect_elements_out_of_canvas_limits_detection_to_whitelist(self) -> None:
issues = xml_text_overlap_lint.detect_elements_out_of_canvas(
[
{"id": "table", "kind": "table", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 45},
{"id": "chart", "kind": "chart", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
{
"id": "text",
"kind": "shape",
"type": "text",
"x": 95,
"y": 0,
"width": 10,
"height": 10,
"rotation": 0,
},
{
"id": "rect",
"kind": "shape",
"type": "rect",
"x": 95,
"y": 0,
"width": 10,
"height": 10,
"rotation": 45,
},
{"id": "image", "kind": "img", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
{
"id": "ellipse",
"kind": "shape",
"type": "ellipse",
"x": 95,
"y": 0,
"width": 10,
"height": 10,
"rotation": 0,
},
],
100,
100,
)
self.assertEqual([issue["elements"] for issue in issues], [["table"], ["chart"], ["text"], ["rect"]])
self.assertEqual(issues[-1]["bbox"], {"x": 95, "y": 0, "width": 10, "height": 10})
def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -1107,8 +1705,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
)
issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 2)
self.assertEqual(issues_by_dimension["width"]["level"], "warning")
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], 2)
self.assertEqual(issues_by_dimension["width"]["level"], "info")
self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch")
self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50])
self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250)
@@ -1201,7 +1800,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
}
script_path = Path(xml_text_overlap_lint.__file__).resolve()
with tempfile.TemporaryDirectory() as temp_dir:
for name, (table_xml, expected_warning_count) in cases.items():
for name, (table_xml, expected_info_count) in cases.items():
with self.subTest(case=name):
input_path = Path(temp_dir) / f"{name}.xml"
input_path.write_text(
@@ -1221,9 +1820,10 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
result = json.loads(completed.stdout)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], expected_warning_count)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], expected_info_count)
self.assertTrue(
all(issue["level"] == "warning" for issue in result["slides"][0]["issues"]),
all(issue["level"] == "info" for issue in result["slides"][0]["issues"]),
result["slides"][0]["issues"],
)
@@ -1268,8 +1868,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_may_cover_vertical_text")
self.assertEqual(issue["level"], "warning")
self.assertEqual(issue["level"], "info")
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["info_count"], 1)
class XmlTextOverlapLintDensityTest(unittest.TestCase):
@@ -1357,7 +1958,7 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
"id": "sparse_container_content",
})
self.assertEqual(issue["measurement"]["container_area"], 151700)
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.032)
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.03)
self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"])
self.assertEqual(issue["element_ids"], ["trend-card", "trend-title", "trend-copy"])
self.assertEqual(
@@ -1842,9 +2443,9 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
# Must match the visual bbox that should_flag_overlap actually decided with (fontSize=14
# from extract_elements), not the fontSize=96 max-descendant value that
# extract_density_elements computes for the same "left" element id.
self.assertEqual(issue["measurement"]["intersection_width"], 117.04)
self.assertEqual(issue["measurement"]["intersection_width"], 109.2)
self.assertEqual(issue["measurement"]["intersection_height"], 6.8)
self.assertEqual(issue["measurement"]["intersection_area"], 795.872)
self.assertEqual(issue["measurement"]["intersection_area"], 742.56)
def test_has_similar_short_card_peer_excludes_the_element_itself(self) -> None:
card_a = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}

View File

@@ -24,6 +24,12 @@ lark-cli task +create \
lark-cli task +create \
--summary "Buy milk"
# Create a milestone by passing an API field without a named flag
lark-cli task +create \
--summary "Release v2.0" \
--due "2026-08-15" \
--data '{"is_milestone":true}'
# Preview the API call without executing
lark-cli task +create --summary "Test Task" --dry-run
```
@@ -39,8 +45,11 @@ lark-cli task +create --summary "Test Task" --dry-run
| `--due <time>` | No | Due date. Supports ISO 8601, `YYYY-MM-DD`, relative time (e.g., `+2d`), or ms timestamp. `YYYY-MM-DD` and relative time will automatically set it as an all-day task. |
| `--tasklist-id <id>` | No | The GUID of the tasklist, or a full AppLink URL (the CLI will automatically extract the `guid` parameter from the URL). |
| `--idempotency-key <key>` | No | Client token to ensure idempotency of the request. |
| `--data <json>` | No | JSON object merged into the task create request for API fields without dedicated flags, such as `{"is_milestone":true}`. Explicit named flags override same-named fields in this object. |
| `--dry-run` | No | Preview the API call (JSON payload) without actually creating the task. |
Use `lark-cli schema task.tasks.create` to confirm that an extra field is supported before passing it through `--data`. Prefer this shortcut over the raw `tasks create` command when `--data` can express the request. Do not assume that other shortcuts support `--data`; check each shortcut's `--help` output first.
## Workflow
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.

View File

@@ -17,10 +17,9 @@ import (
// converts Markdown <-> ClientVars.
const richTextMarkdown = "见 [设计文档](https://bytedance.feishu.cn/docx/abc) 和 **重点**"
// TestCalendar_CreateDescriptionRichDryRun verifies that +create forwards the
// rich-text payload as-is under the description_rich body field, and that even
// when the deprecated --description is also supplied, the CLI sends only
// description_rich (rich wins) and omits the plain description field — the
// TestCalendar_CreateDescriptionRichDryRun verifies that +create treats
// --description as Markdown rich text and forwards it as-is under the
// description_rich body field, omitting the plain description field — the
// service treats the two as mutually exclusive.
func TestCalendar_CreateDescriptionRichDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -38,8 +37,7 @@ func TestCalendar_CreateDescriptionRichDryRun(t *testing.T) {
"--summary", "rich dry-run",
"--start", "2026-04-25T10:00:00+08:00",
"--end", "2026-04-25T11:00:00+08:00",
"--description", "plain fallback",
"--description-rich", richTextMarkdown,
"--description", richTextMarkdown,
"--dry-run",
},
DefaultAs: "bot",
@@ -54,10 +52,10 @@ func TestCalendar_CreateDescriptionRichDryRun(t *testing.T) {
require.False(t, clie2e.DryRunGet(out, "api.0.body.description").Exists(), "plain description must not be sent; stdout:\n%s", out)
}
// TestCalendar_CreateDescriptionRichOnlyDryRun verifies that when only
// --description-rich is provided (no --description), +create omits the
// description body field entirely. Sending an empty description would suppress
// the server's plain-preview backfill and break first-load rendering.
// TestCalendar_CreateDescriptionRichOnlyDryRun verifies that +create forwards
// --description under description_rich and omits the plain description body
// field entirely. Sending an empty description would suppress the server's
// plain-preview backfill and break first-load rendering.
func TestCalendar_CreateDescriptionRichOnlyDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
@@ -74,7 +72,7 @@ func TestCalendar_CreateDescriptionRichOnlyDryRun(t *testing.T) {
"--summary", "rich only dry-run",
"--start", "2026-04-25T10:00:00+08:00",
"--end", "2026-04-25T11:00:00+08:00",
"--description-rich", richTextMarkdown,
"--description", richTextMarkdown,
"--dry-run",
},
DefaultAs: "bot",
@@ -103,7 +101,7 @@ func TestCalendar_UpdateDescriptionRichDryRun(t *testing.T) {
"calendar", "+update",
"--calendar-id", "cal_dry",
"--event-id", "evt_dry",
"--description-rich", richTextMarkdown,
"--description", richTextMarkdown,
"--notify=false",
"--dry-run",
},

View File

@@ -17,11 +17,10 @@ import (
// TestSheets_ImageUploadDryRunParentType pins the parent_type the sheets
// image-upload shortcuts emit in --dry-run output for native vs. imported
// "office" spreadsheets. For native tokens parent_type must be "sheet_image";
// for tokens prefixed with "fake_office_" (the synthetic token an imported
// office spreadsheet carries) the backend requires "office_sheet_file". The
// three covered entries — sheets +media-upload (backward), sheets
// +cells-set-image, and sheets +create-float-image — are every image-upload
// surface that the office/native split fans out to.
// for tokens carrying the interleaved "OFL0X" marker the backend requires
// "office_sheet_file". The covered entries — sheets +media-upload (backward),
// sheets +cells-set-image, and sheets +float-image-create — are every
// image-upload surface that the office/native split fans out to.
func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
setSheetsDryRunEnv(t)
@@ -50,11 +49,11 @@ func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
name: "media-upload office",
args: []string{
"sheets", "+media-upload",
"--spreadsheet-token", "fake_office_dryrun",
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--file", "img.png",
"--dry-run",
},
token: "fake_office_dryrun",
token: "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
wantParentType: "office_sheet_file",
},
{
@@ -74,13 +73,30 @@ func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
name: "cells-set-image office",
args: []string{
"sheets", "+cells-set-image",
"--spreadsheet-token", "fake_office_dryrun",
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--sheet-id", "sheet1",
"--range", "A1",
"--image", "img.png",
"--dry-run",
},
token: "fake_office_dryrun",
token: "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
wantParentType: "office_sheet_file",
},
{
name: "float-image-create office",
args: []string{
"sheets", "+float-image-create",
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--sheet-id", "sheet1",
"--image-name", "img.png",
"--image", "img.png",
"--position-row", "0",
"--position-col", "A",
"--size-width", "100",
"--size-height", "100",
"--dry-run",
},
token: "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
wantParentType: "office_sheet_file",
},
}