mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
50 Commits
v1.0.80
...
feat/sessi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4769b5c3e8 | ||
|
|
c86c348fa9 | ||
|
|
8587203afb | ||
|
|
48b0ade294 | ||
|
|
0fef0667fd | ||
|
|
754f471f75 | ||
|
|
8bcb04c830 | ||
|
|
54428113f5 | ||
|
|
01fdf2f4d6 | ||
|
|
3a9cfdda93 | ||
|
|
51f3f07f44 | ||
|
|
16ed51252e | ||
|
|
6b80706300 | ||
|
|
76bd47a1c8 | ||
|
|
f6732c9afa | ||
|
|
e28f91794a | ||
|
|
dd42477a82 | ||
|
|
fd9940ce5e | ||
|
|
f01f7abbef | ||
|
|
76ee4cd05e | ||
|
|
cf2af70b98 | ||
|
|
7b32b22069 | ||
|
|
0c8bf43023 | ||
|
|
ce00fdcce4 | ||
|
|
7cc881c7a3 | ||
|
|
a340417767 | ||
|
|
ccc6dd3e47 | ||
|
|
beeeb71cd6 | ||
|
|
7c04a1a60e | ||
|
|
eabc8558d5 | ||
|
|
9cad3ca0e1 | ||
|
|
4306f41060 | ||
|
|
d8f877f60f | ||
|
|
6aa8dec967 | ||
|
|
70b611ab2c | ||
|
|
4eb068fc20 | ||
|
|
417777ff58 | ||
|
|
5a0f022a97 | ||
|
|
fe3d94935a | ||
|
|
71521b967d | ||
|
|
1d9f48b62f | ||
|
|
c4f50226a4 | ||
|
|
9b1f3fa01c | ||
|
|
866636563e | ||
|
|
c24a38289a | ||
|
|
e6466b9b14 | ||
|
|
deb16ce1fb | ||
|
|
b6c19f5d1d | ||
|
|
b1d4489657 | ||
|
|
18451622ea |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,7 +1,4 @@
|
||||
/go.mod @liangshuo-1
|
||||
/go.sum @liangshuo-1
|
||||
/internal/ @liangshuo-1
|
||||
/shortcuts/common/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
|
||||
46
.github/workflows/semantic-review.yml
vendored
46
.github/workflows/semantic-review.yml
vendored
@@ -25,16 +25,19 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
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.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}`);
|
||||
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");
|
||||
@@ -250,16 +253,19 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
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.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}`);
|
||||
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");
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -2,61 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: add +member-list shortcut (#1795)
|
||||
- **drive**: add +permission-get-setting shortcut (#1738)
|
||||
- propagate invocation metadata (#2097)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
|
||||
- **slides**: +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: wait for base role update visibility (#2087)
|
||||
|
||||
### Misc
|
||||
|
||||
- Feat/detect line text overlap (#2069)
|
||||
|
||||
## [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
|
||||
@@ -1722,9 +1667,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[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
|
||||
|
||||
@@ -386,7 +386,7 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)
|
||||
|
||||
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 = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
@@ -485,6 +485,18 @@ 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 {
|
||||
|
||||
@@ -27,6 +27,9 @@ 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)
|
||||
|
||||
@@ -4,15 +4,35 @@
|
||||
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,
|
||||
@@ -79,6 +99,51 @@ 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"`
|
||||
|
||||
@@ -6,8 +6,10 @@ package cmd
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -26,5 +28,13 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
|
||||
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
|
||||
return cmdutil.InvocationContext{}, err
|
||||
}
|
||||
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
|
||||
|
||||
profileFromFlag := fs.Changed("profile")
|
||||
if !profileFromFlag {
|
||||
globals.Profile = os.Getenv(envvars.CliProfile)
|
||||
}
|
||||
return cmdutil.InvocationContext{
|
||||
Profile: globals.Profile,
|
||||
ProfileFromFlag: profileFromFlag,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||
@@ -70,3 +74,58 @@ 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,6 +84,16 @@ 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())
|
||||
|
||||
@@ -106,6 +116,77 @@ 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{
|
||||
@@ -481,7 +562,8 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
||||
}{
|
||||
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
|
||||
{"remove", []string{"remove"}},
|
||||
{"show", []string{"show"}},
|
||||
// "show" is deliberately absent: it inspects the SAVED config only
|
||||
// and bypasses this gate (TestConfigShow_BypassesExternalCredentialGate).
|
||||
{"default-as", []string{"default-as", "user"}},
|
||||
{"strict-mode", []string{"strict-mode", "off"}},
|
||||
}
|
||||
|
||||
@@ -27,7 +27,16 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show current configuration",
|
||||
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
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -53,7 +62,10 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
// 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("")
|
||||
if app == nil {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
||||
}
|
||||
|
||||
@@ -110,8 +110,20 @@ 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: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||
return &cmdutil.Factory{Credential: newEventTestCredentialProvider("cli_x", r)}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||
|
||||
@@ -44,7 +44,7 @@ func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Credential: newEventTestCredentialProvider("test-app", &staticTokenResolver{}),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
|
||||
@@ -17,11 +17,14 @@ 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"`
|
||||
Active bool `json:"active"`
|
||||
Default bool `json:"default"`
|
||||
User string `json:"user,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
}
|
||||
@@ -30,7 +33,8 @@ type profileListItem struct {
|
||||
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all profiles",
|
||||
Short: "List saved profiles",
|
||||
Long: "Lists saved profiles. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileListRun(f)
|
||||
},
|
||||
@@ -53,7 +57,7 @@ func profileListRun(f *cmdutil.Factory) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
|
||||
// Intentionally uses "" to show the saved default profile, not the ephemeral --profile override.
|
||||
currentApp := multi.CurrentAppConfig("")
|
||||
currentName := ""
|
||||
if currentApp != nil {
|
||||
@@ -66,10 +70,10 @@ func profileListRun(f *cmdutil.Factory) error {
|
||||
name := app.ProfileName()
|
||||
|
||||
item := profileListItem{
|
||||
Name: name,
|
||||
AppID: app.AppId,
|
||||
Brand: app.Brand,
|
||||
Active: name == currentName,
|
||||
Name: name,
|
||||
AppID: app.AppId,
|
||||
Brand: app.Brand,
|
||||
Default: name == currentName,
|
||||
}
|
||||
|
||||
if len(app.Users) > 0 {
|
||||
|
||||
@@ -14,6 +14,17 @@ 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{
|
||||
|
||||
@@ -306,14 +306,24 @@ 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].Active {
|
||||
t.Fatalf("got[0] = %#v, want active default profile", got[0])
|
||||
if got[0].Name != "default" || !got[0].Default {
|
||||
t.Fatalf("got[0] = %#v, want configured default profile", got[0])
|
||||
}
|
||||
if got[1].Name != "target" || got[1].Active {
|
||||
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
|
||||
if got[1].Name != "target" || got[1].Default {
|
||||
t.Fatalf("got[1] = %#v, want non-default target profile", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,6 +637,39 @@ 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 {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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"
|
||||
)
|
||||
@@ -33,6 +34,15 @@ 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.
|
||||
@@ -58,6 +68,10 @@ 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)
|
||||
},
|
||||
@@ -97,7 +111,17 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
// 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)
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
@@ -122,18 +146,23 @@ 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.
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
|
||||
// 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 {
|
||||
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,
|
||||
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,
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -15,10 +15,13 @@ 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) {
|
||||
@@ -52,7 +55,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)
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
|
||||
|
||||
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
@@ -77,7 +80,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)
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -100,7 +103,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)
|
||||
r := buildResult(cfg, core.AsBot, "default_as", diag, credential.IdentitySelection{})
|
||||
|
||||
if r.Identity != "bot" || r.IdentitySource != "default_as" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
@@ -121,7 +124,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)
|
||||
r := buildResult(cfg, core.AsBot, "auto_detect", diag, credential.IdentitySelection{})
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -318,3 +321,94 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,17 @@ 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
|
||||
|
||||
@@ -136,6 +136,79 @@ 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"},
|
||||
|
||||
@@ -12,8 +12,9 @@ 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)
|
||||
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
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
@@ -41,9 +42,13 @@ 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
|
||||
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
|
||||
)
|
||||
|
||||
// CategoryNetwork subtypes
|
||||
|
||||
@@ -61,9 +61,11 @@ type TypedError interface {
|
||||
// it is intentionally not serialized.
|
||||
type ValidationError struct {
|
||||
Problem
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
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:"-"`
|
||||
}
|
||||
|
||||
// InvalidParam is one structured validation diagnostic: the parameter that
|
||||
@@ -150,6 +152,12 @@ 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.
|
||||
@@ -315,8 +323,18 @@ func (e *PermissionError) WithCause(cause error) *PermissionError {
|
||||
// intentionally not serialized.
|
||||
type ConfigError struct {
|
||||
Problem
|
||||
Field string `json:"field,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
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:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
@@ -370,6 +388,34 @@ 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
|
||||
|
||||
@@ -643,3 +643,29 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
123
extension/credential/env/env.go
vendored
123
extension/credential/env/env.go
vendored
@@ -23,63 +23,89 @@ 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) != ""
|
||||
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
|
||||
}
|
||||
presentKeys := presentCredentialEnvKeys(appID, appSecret, hasUAT, hasTAT)
|
||||
if len(presentKeys) == 0 {
|
||||
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}
|
||||
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
// 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:
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, defaultAs),
|
||||
Code: credential.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliDefaultAs,
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit strict mode policy takes priority
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
strictMode := os.Getenv(envvars.CliStrictMode)
|
||||
var supported credential.IdentitySupport
|
||||
switch strictMode {
|
||||
case "bot":
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
supported = credential.SupportsBot
|
||||
case "user":
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
supported = credential.SupportsUser
|
||||
case "off":
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
supported = credential.SupportsAll
|
||||
case "":
|
||||
// Infer from available tokens
|
||||
if hasUAT {
|
||||
acct.SupportedIdentities |= credential.SupportsUser
|
||||
supported |= credential.SupportsUser
|
||||
}
|
||||
if hasTAT {
|
||||
acct.SupportedIdentities |= credential.SupportsBot
|
||||
supported |= 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:
|
||||
@@ -92,6 +118,35 @@ 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 {
|
||||
|
||||
100
extension/credential/env/env_test.go
vendored
100
extension/credential/env/env_test.go
vendored
@@ -6,6 +6,7 @@ package env
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -47,6 +48,22 @@ 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) {
|
||||
@@ -75,18 +92,81 @@ 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) {
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
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")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
_, 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliAppID) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +338,9 @@ 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)
|
||||
}
|
||||
@@ -276,6 +359,9 @@ 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)
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +94,8 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
@@ -146,6 +148,57 @@ 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)
|
||||
|
||||
|
||||
@@ -44,6 +44,27 @@ 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
|
||||
@@ -53,6 +74,7 @@ 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.
|
||||
@@ -76,11 +98,38 @@ 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
|
||||
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
|
||||
}
|
||||
|
||||
func (e *BlockError) Error() string {
|
||||
|
||||
@@ -48,6 +48,18 @@ 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()
|
||||
@@ -58,7 +70,7 @@ func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Bu
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(httpClient),
|
||||
)
|
||||
testCred := credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil)
|
||||
testCred := newClientTestCredentialProvider("test-app", &staticTokenResolver{})
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
return &APIClient{
|
||||
SDK: sdk,
|
||||
@@ -463,7 +475,7 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
|
||||
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Timeout: 5 * time.Millisecond},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -498,7 +510,7 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
|
||||
})
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Transport: rt},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -532,7 +544,7 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T
|
||||
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -572,7 +584,7 @@ func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.Tok
|
||||
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &needAuthTokenResolver{userOpenID: "ou_test_user"}, nil),
|
||||
Credential: newClientTestCredentialProvider("test-app", &needAuthTokenResolver{userOpenID: "ou_test_user"}),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -612,7 +624,7 @@ func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *t
|
||||
func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ 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 {
|
||||
|
||||
@@ -63,10 +63,11 @@ 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,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
ProfileFromFlag: inv.ProfileFromFlag,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
|
||||
// Phase 3: Runtime config contains resolved account data only.
|
||||
@@ -174,10 +175,11 @@ func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
ProfileFromFlag bool
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
|
||||
@@ -190,5 +192,13 @@ 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.
|
||||
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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"
|
||||
@@ -405,6 +406,14 @@ 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
|
||||
@@ -448,6 +457,86 @@ 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
|
||||
|
||||
@@ -26,7 +26,6 @@ const (
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderAgentName = "X-Agent-Name"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
@@ -56,9 +55,6 @@ func BaseSecurityHeaders() http.Header {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
if v := envvars.AgentName(); v != "" {
|
||||
h.Set(HeaderAgentName, v)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -263,34 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent headers injected via BaseSecurityHeaders
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(envvars.CliAgentName, agentName)
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != agentName {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -255,7 +255,11 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
}
|
||||
|
||||
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
|
||||
// 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").
|
||||
WithHint("%s", err.Error()).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
@@ -36,16 +36,13 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
return nil, NotConfiguredError()
|
||||
|
||||
154
internal/credential/authsidecar_contract_test.go
Normal file
154
internal/credential/authsidecar_contract_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,17 @@ 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.
|
||||
@@ -136,10 +142,21 @@ 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
|
||||
@@ -161,49 +178,521 @@ 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 {
|
||||
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)
|
||||
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
|
||||
}
|
||||
// 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 = ""
|
||||
break
|
||||
}
|
||||
p.selectedSource = source
|
||||
return internal, nil
|
||||
// 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
|
||||
}
|
||||
}
|
||||
if p.defaultAcct != 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.
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// 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)
|
||||
}
|
||||
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
||||
return acct, nil
|
||||
// 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
|
||||
}
|
||||
return nil, core.NotConfiguredError()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// enrichUserInfo resolves user identity when extension provides a UAT.
|
||||
@@ -239,17 +728,13 @@ func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account,
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) {
|
||||
if p.selectedSource != nil {
|
||||
return p.selectedSource, nil
|
||||
}
|
||||
if p.defaultAcct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := p.ResolveAccount(ctx); err != nil {
|
||||
if _, err := p.resolveAccountSelection(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.selectedSource == nil {
|
||||
return nil, fmt.Errorf("credential provider resolved an account without selecting a token source")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider resolved an account without selecting a token source").
|
||||
WithHint("retry the command.")
|
||||
}
|
||||
return p.selectedSource, nil
|
||||
}
|
||||
@@ -302,51 +787,88 @@ func (p *CredentialProvider) doResolveIdentityHint(ctx context.Context) (*Identi
|
||||
|
||||
// ResolveToken resolves an access token.
|
||||
func (p *CredentialProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
source, err := p.selectedCredentialSource(ctx)
|
||||
acct, err := p.resolveAccountSelection(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if source != nil {
|
||||
return resolveTokenFromSource(ctx, source, req)
|
||||
if acct == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider resolved no account before %s token resolution", req.Type).
|
||||
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
|
||||
}
|
||||
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.")
|
||||
}
|
||||
source = defaultTokenSource{resolver: p.defaultToken}
|
||||
result, found, err := source.TryResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if req.AppID == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"TokenSpec.AppID is required for %s token resolution", req.Type).
|
||||
WithHint("retry the command.")
|
||||
}
|
||||
if found {
|
||||
return result, nil
|
||||
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.")
|
||||
}
|
||||
return nil, &TokenUnavailableError{Type: req.Type}
|
||||
return resolveTokenFromSource(ctx, source, req)
|
||||
}
|
||||
|
||||
// ActiveExtensionProviderName reports whether an extension provider is managing
|
||||
// credentials. It probes p.providers (extension providers only, not defaultAcct)
|
||||
// and returns the name of the first engaged provider.
|
||||
// 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.
|
||||
//
|
||||
// "Engaged" means: ResolveAccount returns a non-nil account, OR returns a
|
||||
// *extcred.BlockError (provider configured but misconfigured — still counts as
|
||||
// external). Any other error is propagated to the caller.
|
||||
// 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?
|
||||
//
|
||||
// Returns ("", nil) when no extension provider is active (built-in keychain path).
|
||||
// Safe to call multiple times — probes providers directly without the sync.Once cache.
|
||||
// Safe to call multiple times: explicit-profile resolution uses sync.Once, while
|
||||
// the probe path only consults providers.
|
||||
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()
|
||||
|
||||
1124
internal/credential/credential_provider_selection_test.go
Normal file
1124
internal/credential/credential_provider_selection_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ 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"
|
||||
@@ -23,6 +24,7 @@ type mockExtProvider struct {
|
||||
err error
|
||||
accountErr error
|
||||
tokenErr error
|
||||
tokenCalls int
|
||||
}
|
||||
|
||||
func (m *mockExtProvider) Name() string { return m.name }
|
||||
@@ -33,6 +35,7 @@ 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
|
||||
}
|
||||
@@ -49,11 +52,13 @@ func (m *mockDefaultAcct) ResolveAccount(ctx context.Context) (*Account, error)
|
||||
}
|
||||
|
||||
type mockDefaultToken struct {
|
||||
result *TokenResult
|
||||
err error
|
||||
result *TokenResult
|
||||
err error
|
||||
tokenCalls int
|
||||
}
|
||||
|
||||
func (m *mockDefaultToken) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
m.tokenCalls++
|
||||
return m.result, m.err
|
||||
}
|
||||
|
||||
@@ -116,35 +121,45 @@ func TestCredentialProvider_AccountCached(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenFromExtension(t *testing.T) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenFallsToDefault(t *testing.T) {
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "skip"}},
|
||||
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
defaultToken, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
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) {
|
||||
@@ -159,7 +174,7 @@ func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken() error = %v", err)
|
||||
}
|
||||
@@ -181,7 +196,7 @@ func TestCredentialProvider_SelectedSourceWithoutTokenReturnsUnavailableError(t
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want unavailable error")
|
||||
}
|
||||
@@ -202,7 +217,7 @@ func TestCredentialProvider_ResolveTokenPropagatesNonBlockExtensionError(t *test
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
if err == nil || err.Error() != "provider exploded" {
|
||||
t.Fatalf("ResolveToken() error = %v, want provider exploded", err)
|
||||
}
|
||||
@@ -312,12 +327,12 @@ func TestCredentialProvider_ResolveIdentityHint_CachesResult(t *testing.T) {
|
||||
func TestCredentialProvider_ResolveTokenTreatsEmptyDefaultTokenAsMalformed(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
nil,
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: ""}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty token") {
|
||||
t.Fatalf("ResolveToken() error = %v, want malformed empty token error", err)
|
||||
}
|
||||
@@ -410,17 +425,189 @@ 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")},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
defaultToken,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
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) {
|
||||
|
||||
181
internal/credential/decide_test.go
Normal file
181
internal/credential/decide_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -74,9 +74,13 @@ 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.
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
// 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()
|
||||
if err != nil {
|
||||
return nil, core.NotConfiguredError()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
||||
@@ -116,6 +120,7 @@ type DefaultTokenProvider struct {
|
||||
|
||||
tatOnce sync.Once
|
||||
tatResult *TokenResult
|
||||
tatAppID string
|
||||
tatErr error
|
||||
}
|
||||
|
||||
@@ -126,21 +131,42 @@ 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)
|
||||
return p.resolveUAT(ctx, req)
|
||||
case TokenTypeTAT:
|
||||
return p.resolveTAT(ctx)
|
||||
return p.resolveTAT(ctx, req)
|
||||
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) (*TokenResult, error) {
|
||||
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context, req TokenSpec) (*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
|
||||
@@ -157,20 +183,36 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
|
||||
return &TokenResult{Token: token, Scopes: scopes}, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
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
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
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) {
|
||||
@@ -92,3 +97,136 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
54
internal/credential/identity_selection.go
Normal file
54
internal/credential/identity_selection.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
25
internal/credential/identity_selection_test.go
Normal file
25
internal/credential/identity_selection_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,24 @@ 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{}
|
||||
@@ -59,7 +77,8 @@ func TestFullChain_Fallthrough(t *testing.T) {
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
nil, mock, nil,
|
||||
&mockDefaultAccountProvider{account: &credential.Account{AppID: "app1"}},
|
||||
mock, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT, AppID: "app1",
|
||||
@@ -72,6 +91,14 @@ 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
|
||||
|
||||
@@ -21,6 +21,7 @@ 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"
|
||||
|
||||
@@ -16,18 +16,16 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, agentName)
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, agentName)
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, " "+agentName+" ")
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.80",
|
||||
"version": "1.0.77",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.80",
|
||||
"version": "1.0.77",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.80",
|
||||
"version": "1.0.77",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -176,15 +176,7 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
|
||||
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"
|
||||
@@ -209,10 +201,7 @@ 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" '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" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
|
||||
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"
|
||||
|
||||
@@ -250,7 +250,7 @@ var CalendarAgenda = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
collapseDescription(e)
|
||||
backfillDescriptionRich(e)
|
||||
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
|
||||
if rrule := runtime.Str("rrule"); rrule != "" {
|
||||
eventData["recurrence"] = rrule
|
||||
}
|
||||
if description := descriptionToSend(runtime); description != "" {
|
||||
eventData["description_rich"] = description
|
||||
if descriptionRich := descriptionRichToSend(runtime); descriptionRich != "" {
|
||||
eventData["description_rich"] = descriptionRich
|
||||
}
|
||||
return eventData
|
||||
}
|
||||
@@ -120,7 +120,8 @@ 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: "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 (``; 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`, `<br>**bold**`).", Input: []string{common.File, common.Stdin}},
|
||||
{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 (``; 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`, `<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)"},
|
||||
@@ -233,7 +234,7 @@ var CalendarCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
|
||||
if err := resolveDescriptionRichImages(runtime, calendarId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
|
||||
if status, _ := out["status"].(string); status != "cancelled" {
|
||||
delete(out, "status")
|
||||
}
|
||||
collapseDescription(out)
|
||||
backfillDescriptionRich(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -988,9 +988,8 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured patch body: %v", err)
|
||||
}
|
||||
// --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).
|
||||
// The deprecated, hidden --description folds into 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)
|
||||
}
|
||||
@@ -1412,17 +1411,18 @@ func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
if !strings.Contains(out, "\"description\": \"just text\"") {
|
||||
t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
|
||||
t.Errorf("expected plain description retained for plain-only event, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
if !strings.Contains(out, "\"description_rich\": \"just text\"") {
|
||||
t.Errorf("expected description_rich backfilled from plain, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3438,8 +3438,7 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGet_UnifiesDescriptionRich(t *testing.T) {
|
||||
// Read exposes a single unified description field carrying the rich value
|
||||
// when present, and the plain text otherwise; description_rich is dropped.
|
||||
// Read keeps both description (plain) and description_rich (rich).
|
||||
t.Run("rich present", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -3463,16 +3462,17 @@ func TestGet_UnifiesDescriptionRich(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "| a | b |") {
|
||||
t.Errorf("expected rich value surfaced under description, got: %s", out)
|
||||
if !strings.Contains(out, "\"description\": \"[表格]\"") {
|
||||
t.Errorf("expected plain description retained, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
if !strings.Contains(out, "\"description_rich\":") {
|
||||
t.Errorf("expected description_rich in output, got: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
// When only a plain description exists, it is surfaced under description.
|
||||
t.Run("only plain surfaces under description", func(t *testing.T) {
|
||||
// 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) {
|
||||
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 surfaced, got: %s", out)
|
||||
t.Errorf("expected plain description retained, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
if !strings.Contains(out, "\"description_rich\": \"just text\"") {
|
||||
t.Errorf("expected description_rich backfilled from plain, got: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ 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: "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 (``; 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`, `<br>**bold**`). Passing an empty string clears the description.", Input: []string{common.File, common.Stdin}},
|
||||
{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 (``; 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`, `<br>**bold**`).", 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)"},
|
||||
@@ -71,7 +72,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, --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, --description-rich, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -113,7 +114,10 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
|
||||
body["summary"] = runtime.Str("summary")
|
||||
hasFields = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("description") {
|
||||
if runtime.Cmd.Flags().Changed("description-rich") {
|
||||
body["description_rich"] = runtime.Str("description-rich")
|
||||
hasFields = true
|
||||
} else if runtime.Cmd.Flags().Changed("description") {
|
||||
body["description_rich"] = runtime.Str("description")
|
||||
hasFields = true
|
||||
}
|
||||
@@ -358,8 +362,11 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
|
||||
}
|
||||
|
||||
if runtime.Cmd.Flags().Changed("description") {
|
||||
if err := resolveDescriptionImages(runtime, calendarID); err != nil {
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -436,10 +443,19 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
|
||||
if summary, _ := event["summary"].(string); summary != "" {
|
||||
result["summary"] = summary
|
||||
}
|
||||
if rich, _ := event["description_rich"].(string); rich != "" {
|
||||
result["description"] = rich
|
||||
} else if plain, _ := event["description"].(string); plain != "" {
|
||||
result["description"] = plain
|
||||
// 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 start := formatCalendarEventTime(event["start_time"]); start != "" {
|
||||
result["start"] = start
|
||||
|
||||
@@ -27,8 +27,8 @@ const calendarMediaParentType = "calendar"
|
||||
|
||||
var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
|
||||
|
||||
func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
|
||||
md := runtime.Str("description")
|
||||
func resolveDescriptionRichImages(runtime *common.RuntimeContext, calendarID string) error {
|
||||
md := runtime.Str("description-rich")
|
||||
if md == "" || !strings.Contains(md, "",
|
||||
"--description-rich", "",
|
||||
"--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", "",
|
||||
"--description-rich", "",
|
||||
"--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 validation error before any API call.
|
||||
// yields a typed --description-rich 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", "",
|
||||
"--description-rich", "",
|
||||
"--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" {
|
||||
t.Errorf("param = %q, want --description", ve.Param)
|
||||
if ve.Param != "--description-rich" {
|
||||
t.Errorf("param = %q, want --description-rich", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,23 +30,20 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
|
||||
return startInput, endInput
|
||||
}
|
||||
|
||||
func collapseDescription(event map[string]interface{}) {
|
||||
func backfillDescriptionRich(event map[string]interface{}) {
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
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")
|
||||
if descRich, _ := event["description_rich"].(string); descRich == "" {
|
||||
if desc, _ := event["description"].(string); desc != "" {
|
||||
event["description_rich"] = desc
|
||||
}
|
||||
}
|
||||
}
|
||||
func descriptionToSend(runtime *common.RuntimeContext) string {
|
||||
func descriptionRichToSend(runtime *common.RuntimeContext) string {
|
||||
if v := runtime.Str("description-rich"); v != "" {
|
||||
return v
|
||||
}
|
||||
return runtime.Str("description")
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,18 @@ 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
|
||||
@@ -106,7 +118,7 @@ func TestEnhancePermissionError_PermissionErrorGetsScopeHint(t *testing.T) {
|
||||
|
||||
func TestCheckShortcutScopes_PropagatesContextCancellation(t *testing.T) {
|
||||
f := &cmdutil.Factory{
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{err: context.Canceled}, nil),
|
||||
Credential: newScopeCheckCredentialProvider("app-1", &scopeCheckTokenResolver{err: context.Canceled}),
|
||||
}
|
||||
|
||||
err := checkShortcutScopes(f, context.Background(), core.AsUser, &core.CliConfig{AppID: "app-1"}, []string{"im:message:read"})
|
||||
@@ -124,9 +136,9 @@ func TestCheckShortcutScopes_PropagatesContextCancellation(t *testing.T) {
|
||||
// command for human consumers.
|
||||
func TestCheckShortcutScopes_ReturnsTypedPermissionError(t *testing.T) {
|
||||
f := &cmdutil.Factory{
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{
|
||||
Credential: newScopeCheckCredentialProvider("app-1", &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"}
|
||||
@@ -168,7 +180,7 @@ func TestCheckShortcutScopes_ReturnsTypedPermissionError(t *testing.T) {
|
||||
|
||||
func TestCheckShortcutScopes_IgnoresNonContextTokenErrors(t *testing.T) {
|
||||
f := &cmdutil.Factory{
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{err: errors.New("token cache unavailable")}, nil),
|
||||
Credential: newScopeCheckCredentialProvider("app-1", &scopeCheckTokenResolver{err: errors.New("token cache unavailable")}),
|
||||
}
|
||||
|
||||
err := checkShortcutScopes(f, context.Background(), core.AsUser, &core.CliConfig{AppID: "app-1"}, []string{"im:message:read"})
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveMemberListSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
Fields string
|
||||
PermType string
|
||||
}
|
||||
|
||||
var driveMemberListTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
}
|
||||
|
||||
var driveMemberListFields = []string{"name", "type", "avatar", "external_label"}
|
||||
var driveMemberListPermTypes = []string{"container", "single_page"}
|
||||
|
||||
var driveMemberListURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) {
|
||||
token, resourceType, err := resolveDriveMemberListTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
fields, err := normalizeDriveMemberListFields(runtime.Str("fields"), runtime.Changed("fields"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
permType, err := normalizeDriveMemberListPermType(runtime.Str("perm-type"), resourceType, runtime.Changed("perm-type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
return driveMemberListSpec{
|
||||
Token: token,
|
||||
Type: resourceType,
|
||||
Fields: fields,
|
||||
PermType: permType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
|
||||
explicitType, err = normalizeDriveMemberListEnumValue(explicitType, driveMemberListTypes, "--type")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
parsed, parseErr := url.Parse(raw)
|
||||
if parseErr != nil || parsed.Hostname() == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
|
||||
}
|
||||
ref, ok := parseDriveMemberListResourceURLPath(parsed.Path)
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
raw,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return ref.Token, ref.Type, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(driveMemberListTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(raw, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return raw, explicitType, nil
|
||||
}
|
||||
|
||||
func parseDriveMemberListResourceURLPath(path string) (common.ResourceRef, bool) {
|
||||
for _, mapping := range driveMemberListURLPathToType {
|
||||
if !strings.HasPrefix(path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListFields(raw string, changed bool) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
if changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields cannot be blank; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
fields := make([]string, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, part := range parts {
|
||||
field := strings.ToLower(strings.TrimSpace(part))
|
||||
if field == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields contains an empty field; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
if field == "*" {
|
||||
if len(parts) != 1 {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields=* cannot be combined with other fields").WithParam("--fields")
|
||||
}
|
||||
return "*", nil
|
||||
}
|
||||
if !driveMemberListFieldAllowed(field) {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for --fields, allowed: %s, *",
|
||||
strings.TrimSpace(part),
|
||||
strings.Join(driveMemberListFields, ", "),
|
||||
).WithParam("--fields")
|
||||
}
|
||||
if !seen[field] {
|
||||
fields = append(fields, field)
|
||||
seen[field] = true
|
||||
}
|
||||
}
|
||||
return strings.Join(fields, ","), nil
|
||||
}
|
||||
|
||||
func driveMemberListFieldAllowed(field string) bool {
|
||||
for _, allowed := range driveMemberListFields {
|
||||
if field == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListPermType(raw, resourceType string, changed bool) (string, error) {
|
||||
permType, err := normalizeDriveMemberListEnumValue(raw, driveMemberListPermTypes, "--perm-type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resourceType != "wiki" && changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
|
||||
}
|
||||
return permType, nil
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListEnumValue(raw string, allowed []string, flagName string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if strings.EqualFold(value, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for %s, allowed: %s",
|
||||
value,
|
||||
flagName,
|
||||
strings.Join(allowed, ", "),
|
||||
).WithParam(flagName)
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) apiPath() string {
|
||||
return fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(s.Token))
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) params() map[string]interface{} {
|
||||
params := map[string]interface{}{"type": s.Type}
|
||||
if s.Fields != "" {
|
||||
params["fields"] = s.Fields
|
||||
}
|
||||
if s.PermType != "" {
|
||||
params["perm_type"] = s.PermType
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// DriveMemberList lists collaborator/member permissions on a Drive resource.
|
||||
var DriveMemberList = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+member-list",
|
||||
Description: "List collaborator/member permissions on a Drive document, file, folder, or wiki node",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.member:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"},
|
||||
{Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"},
|
||||
{Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders.",
|
||||
"--fields is omitted by default; pass --fields '*' or a comma-separated subset when extra collaborator fields are needed.",
|
||||
"--perm-type only applies to wiki nodes.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List Drive collaborator/member permissions").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive members for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped("GET", spec.apiPath(), spec.params(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if items, ok := data["items"].([]interface{}); ok {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Found %d Drive member(s)\n", len(items))
|
||||
}
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderDriveMemberListPretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func renderDriveMemberListPretty(w io.Writer, data map[string]interface{}) {
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No Drive members found.")
|
||||
return
|
||||
}
|
||||
for i, raw := range items {
|
||||
member, _ := raw.(map[string]interface{})
|
||||
fmt.Fprintf(w, "[%d] %s\n", i+1, driveMemberListValue(member["member_id"]))
|
||||
fmt.Fprintf(w, " member_type: %s\n", driveMemberListValue(member["member_type"]))
|
||||
fmt.Fprintf(w, " perm: %s\n", driveMemberListValue(member["perm"]))
|
||||
if permType := driveMemberListValue(member["perm_type"]); permType != "-" {
|
||||
fmt.Fprintf(w, " perm_type: %s\n", permType)
|
||||
}
|
||||
if memberType := driveMemberListValue(member["type"]); memberType != "-" {
|
||||
fmt.Fprintf(w, " type: %s\n", memberType)
|
||||
}
|
||||
if name := driveMemberListValue(member["name"]); name != "-" {
|
||||
fmt.Fprintf(w, " name: %s\n", name)
|
||||
}
|
||||
if avatar := driveMemberListValue(member["avatar"]); avatar != "-" {
|
||||
fmt.Fprintf(w, " avatar: %s\n", avatar)
|
||||
}
|
||||
if label, ok := member["external_label"]; ok {
|
||||
fmt.Fprintf(w, " external_label: %v\n", label)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func driveMemberListValue(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDriveMemberListRuntime(t *testing.T, token, docType, fields, permType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +member-list"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("fields", "", "")
|
||||
cmd.Flags().String("perm-type", "", "")
|
||||
for name, value := range map[string]string{
|
||||
"token": token,
|
||||
"type": docType,
|
||||
"fields": fields,
|
||||
"perm-type": permType,
|
||||
} {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, "", "")
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok || spec.Type != tt.wantType {
|
||||
t.Fatalf("spec token/type = %q/%q, want %q/%q", spec.Token, spec.Type, tt.wantTok, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "folder",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "invalid fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "name,unknown",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "star mixed with fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "*,name",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "cannot be combined",
|
||||
},
|
||||
{
|
||||
name: "perm type rejected for non-wiki",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
permType: "single_page",
|
||||
wantParam: "--perm-type",
|
||||
wantMessage: "only applies when resource type is wiki",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "default omits optional params",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
want: map[string]interface{}{"type": "docx"},
|
||||
},
|
||||
{
|
||||
name: "fields canonicalized and deduplicated",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "Name,avatar,name",
|
||||
want: map[string]interface{}{"type": "docx", "fields": "name,avatar"},
|
||||
},
|
||||
{
|
||||
name: "wiki accepts perm type",
|
||||
token: "wikTok",
|
||||
docType: "WIKI",
|
||||
fields: "*",
|
||||
permType: "SINGLE_PAGE",
|
||||
want: map[string]interface{}{"type": "wiki", "fields": "*", "perm_type": "single_page"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if got := spec.params(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("params = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "https://example.feishu.cn/drive/folder/fldTok",
|
||||
"--fields", "*",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(got.Data.API) != 1 {
|
||||
t.Fatalf("api count = %d, want 1", len(got.Data.API))
|
||||
}
|
||||
api := got.Data.API[0]
|
||||
if api.Method != "GET" || api.URL != "/open-apis/drive/v1/permissions/fldTok/members" {
|
||||
t.Fatalf("api = %#v", api)
|
||||
}
|
||||
if api.Params["type"] != "folder" || api.Params["fields"] != "*" {
|
||||
t.Fatalf("params = %#v", api.Params)
|
||||
}
|
||||
if _, ok := api.Params["perm_type"]; ok {
|
||||
t.Fatalf("perm_type should be omitted for folder: %#v", api.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListExecutePreservesRawData(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
var capturedQuery string
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/doxTok/members",
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
"server_future": "preserved",
|
||||
"external_label": true,
|
||||
},
|
||||
},
|
||||
"server_top_level": "preserved",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--fields", "name,type,external_label",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(capturedQuery, "type=docx") ||
|
||||
!strings.Contains(capturedQuery, "fields=name%2Ctype%2Cexternal_label") {
|
||||
t.Fatalf("captured query = %q", capturedQuery)
|
||||
}
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["server_top_level"] != "preserved" {
|
||||
t.Fatalf("server_top_level = %#v", data["server_top_level"])
|
||||
}
|
||||
for _, key := range []string{"token", "type", "count"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %#v, want one item", data["items"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if item["server_future"] != "preserved" || item["external_label"] != true {
|
||||
t.Fatalf("item future fields not preserved: %#v", item)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Found 1 Drive member") {
|
||||
t.Fatalf("stderr = %q, want count log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DriveMemberList.Scopes, []string{"docs:permission.member:retrieve"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.member:retrieve", DriveMemberList.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DriveMemberList.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DriveMemberList.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListPrettyOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/wikTok/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"perm_type": "single_page",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "wikTok",
|
||||
"--type", "wiki",
|
||||
"--perm-type", "single_page",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"[1] ou_x", "member_type: openid", "perm_type: single_page", "name: zhangsan"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type drivePermissionGetSettingSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePermissionGetSettingSpec, error) {
|
||||
rawToken := strings.TrimSpace(runtime.Str("token"))
|
||||
explicitType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
|
||||
|
||||
if rawToken == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token is required",
|
||||
).WithParam("--token")
|
||||
}
|
||||
|
||||
if explicitType != "" && !drivePermissionGetSettingTypeAllowed(explicitType) {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid --type %q: allowed values are %s",
|
||||
explicitType,
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if strings.Contains(rawToken, "://") {
|
||||
ref, ok := parseDrivePermissionGetSettingResourceURL(rawToken)
|
||||
if !ok {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
rawToken,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: ref.Token, Type: ref.Type}, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token (allowed: %s)",
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(rawToken, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: rawToken, Type: explicitType}, nil
|
||||
}
|
||||
|
||||
func parseDrivePermissionGetSettingResourceURL(rawURL string) (common.ResourceRef, bool) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
for _, mapping := range drivePermissionGetSettingURLPathToType {
|
||||
if !strings.HasPrefix(parsed.Path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := parsed.Path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingTypeAllowed(docType string) bool {
|
||||
for _, allowed := range drivePermissionGetSettingTypes {
|
||||
if docType == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string {
|
||||
if runtime != nil && runtime.Config != nil {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, s.Type, s.Token); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return common.BuildResourceURL("", s.Type, s.Token)
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) params() map[string]interface{} {
|
||||
return map[string]interface{}{"type": s.Type}
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) apiPath() string {
|
||||
return drivePermissionPublicV2Path(s.Token)
|
||||
}
|
||||
|
||||
func drivePermissionPublicV2Path(token string) string {
|
||||
return fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token))
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingPermissionPublic(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
permissionPublic := common.GetMap(data, "permission_public")
|
||||
if permissionPublic == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"drive permission get response missing data.permission_public",
|
||||
)
|
||||
}
|
||||
return permissionPublic, nil
|
||||
}
|
||||
|
||||
// DrivePermissionGetSetting queries permission_public settings for a Drive
|
||||
// document, file, wiki node, or folder.
|
||||
var DrivePermissionGetSetting = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+permission-get-setting",
|
||||
Description: "Get public access, sharing, collaborator management, security, and comment permission settings",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.setting:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens", Enum: drivePermissionGetSettingTypes},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders. This shortcut reads the target's own permission settings; it does not recurse into child documents.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Get Drive permission settings").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Getting permission settings for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
spec.apiPath(),
|
||||
spec.params(),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
permissionPublic, err := drivePermissionGetSettingPermissionPublic(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissionPublicPretty, err := json.MarshalIndent(permissionPublic, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"encode drive permission settings for pretty output",
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{"permission_public": permissionPublic}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Type: %s\n", spec.Type)
|
||||
fmt.Fprintf(w, "Token: %s\n", spec.Token)
|
||||
if url := spec.url(runtime); url != "" {
|
||||
fmt.Fprintf(w, "URL: %s\n", url)
|
||||
}
|
||||
fmt.Fprintf(w, "Permission settings:\n%s\n", permissionPublicPretty)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDrivePermissionGetSettingRuntime(t *testing.T, token, docType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +permission-get-setting"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
if token != "" {
|
||||
if err := cmd.Flags().Set("token", token); err != nil {
|
||||
t.Fatalf("set --token: %v", err)
|
||||
}
|
||||
}
|
||||
if docType != "" {
|
||||
if err := cmd.Flags().Set("type", docType); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "wiki URL",
|
||||
token: "https://example.feishu.cn/wiki/wikTok",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare file token",
|
||||
token: "boxTok",
|
||||
docType: "file",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok {
|
||||
t.Fatalf("Token = %q, want %q", spec.Token, tt.wantTok)
|
||||
}
|
||||
if spec.Type != tt.wantType {
|
||||
t.Fatalf("Type = %q, want %q", spec.Type, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "sheet",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid --type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if validationErr, ok := err.(*errs.ValidationError); ok {
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
} else {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantURL string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: "fldTok",
|
||||
docType: "folder",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/doxTok/public",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantURL: "/open-apis/drive/v2/permissions/wikTok/public",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/boxTok/public",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/obTok/public",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/mndTok/public",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
dry := DrivePermissionGetSetting.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
out := string(data)
|
||||
for _, want := range []string{
|
||||
`"` + tt.wantURL + `"`,
|
||||
`"GET"`,
|
||||
`"type":"` + tt.wantType + `"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `"folder_token"`) {
|
||||
t.Fatalf("dry-run output contains folder_token, want omitted:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePreservesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
for _, key := range []string{"type", "token", "url"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want field omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
permissionPublic, _ := data["permission_public"].(map[string]interface{})
|
||||
if permissionPublic == nil {
|
||||
t.Fatalf("permission_public missing in output: %#v", data)
|
||||
}
|
||||
for key, want := range map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
} {
|
||||
if permissionPublic[key] != want {
|
||||
t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecuteRejectsMissingPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{"unexpected": "response"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid response error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = %#v, want internal/invalid_response", problem)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should be empty on invalid response, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Permission settings:",
|
||||
`"link_share_entity": "closed"`,
|
||||
`"server_future_field": "preserved"`,
|
||||
} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.Scopes, []string{"docs:permission.setting:read"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.setting:read", DrivePermissionGetSetting.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DrivePermissionGetSetting.AuthTypes)
|
||||
}
|
||||
for _, flag := range DrivePermissionGetSetting.Flags {
|
||||
if flag.Name == "token" && !flag.Required {
|
||||
t.Fatal("--token must be declared required")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,18 @@ 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) {
|
||||
@@ -308,7 +320,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 = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
|
||||
f.Credential = newDriveTestCredentialProvider(driveTestConfig().AppID, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
@@ -357,7 +369,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 = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
|
||||
f.Credential = newDriveTestCredentialProvider(driveTestConfig().AppID, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
@@ -22,7 +22,6 @@ 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"
|
||||
@@ -1593,7 +1592,7 @@ func TestDriveSyncAskConflictEOFDuringPlanningPreventsAnyWrites(t *testing.T) {
|
||||
|
||||
func TestDriveSyncDryRunQuickAcceptsMetadataOnlyScope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
|
||||
f.Credential = newDriveTestCredentialProvider(driveTestConfig().AppID, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
@@ -1622,7 +1621,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 = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly drive:file:download"}, nil)
|
||||
f.Credential = newDriveTestCredentialProvider(syncTestConfig.AppID, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly drive:file:download"})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
@@ -329,7 +329,7 @@ func newDriveTaskResultRuntimeWithScopes(t *testing.T, as core.Identity, scopes
|
||||
|
||||
cfg := driveTestConfig()
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockDriveTaskResultTokenResolver{scopes: scopes}, nil)
|
||||
factory.Credential = newDriveTestCredentialProvider(cfg.AppID, &mockDriveTaskResultTokenResolver{scopes: scopes})
|
||||
|
||||
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 = credential.NewCredentialProvider(nil, nil, cancelingTokenResolver{}, nil)
|
||||
factory.Credential = newDriveTestCredentialProvider(cfg.AppID, cancelingTokenResolver{})
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, core.AsUser)
|
||||
runtime.Factory = factory
|
||||
|
||||
@@ -32,8 +32,6 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveMemberAdd,
|
||||
DriveMemberList,
|
||||
DrivePermissionGetSetting,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
|
||||
@@ -39,8 +39,6 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+member-add",
|
||||
"+member-list",
|
||||
"+permission-get-setting",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
|
||||
@@ -27,6 +27,14 @@ 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) {
|
||||
@@ -68,7 +76,12 @@ func newBotConvertlibRuntime(t *testing.T, rt http.RoundTripper) *common.Runtime
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
testCred := credential.NewCredentialProvider(nil, nil, &staticConvertlibTokenResolver{}, nil)
|
||||
testCred := credential.NewCredentialProvider(
|
||||
nil,
|
||||
convertlibTestAccountResolver{appID: cfg.AppID},
|
||||
&staticConvertlibTokenResolver{},
|
||||
nil,
|
||||
)
|
||||
runtime := &common.RuntimeContext{
|
||||
Config: cfg,
|
||||
Factory: &cmdutil.Factory{
|
||||
|
||||
@@ -37,6 +37,18 @@ 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) {
|
||||
@@ -90,7 +102,7 @@ func newBotShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeCo
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
testCred := credential.NewCredentialProvider(nil, nil, &staticShortcutTokenResolver{}, nil)
|
||||
testCred := newIMTestCredentialProvider(cfg.AppID, &staticShortcutTokenResolver{})
|
||||
runtime := &common.RuntimeContext{
|
||||
Config: cfg,
|
||||
Factory: &cmdutil.Factory{
|
||||
|
||||
@@ -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 = credential.NewCredentialProvider(nil, nil, scopedTokenResolver{scopes: scopes}, nil)
|
||||
rt.Factory.Credential = newIMTestCredentialProvider(rt.Config.AppID, scopedTokenResolver{scopes: scopes})
|
||||
}
|
||||
|
||||
func setRuntimeTokenError(t *testing.T, rt *common.RuntimeContext, err error) {
|
||||
t.Helper()
|
||||
rt.Factory.Credential = credential.NewCredentialProvider(nil, nil, errorTokenResolver{err: err}, nil)
|
||||
rt.Factory.Credential = newIMTestCredentialProvider(rt.Config.AppID, errorTokenResolver{err: err})
|
||||
}
|
||||
|
||||
func TestFlagMessageID(t *testing.T) {
|
||||
|
||||
@@ -17,10 +17,9 @@ import (
|
||||
)
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// 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.
|
||||
// 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.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
@@ -28,37 +27,22 @@ const (
|
||||
localOfficePrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officePrefixes are the legacy synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry.
|
||||
// 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.
|
||||
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
|
||||
|
||||
func isOfficeSpreadsheet(spreadsheetToken string) bool {
|
||||
// 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 {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return true
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
|
||||
"--spreadsheet-token", "fake_office_abc123",
|
||||
"--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 interleaved OFL0X token, got: %s", out)
|
||||
t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"sheet_image"`) {
|
||||
t.Fatalf("dry-run must not emit sheet_image for interleaved OFL0X token, got: %s", out)
|
||||
t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported
|
||||
// "office" spreadsheet (token carrying the interleaved "OFL0X" marker) uploads with
|
||||
// "office" spreadsheet (token prefixed with "fake_office_") 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 = "aaaaOaaaaFaaaaLaaaa0aaaaXaaa"
|
||||
const officeToken = "fake_office_abc123"
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", officeToken,
|
||||
|
||||
@@ -53,10 +53,9 @@ 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 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.
|
||||
// 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.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
@@ -64,38 +63,21 @@ const (
|
||||
localOfficePrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officePrefixes are the legacy synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry.
|
||||
// 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.
|
||||
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 {
|
||||
if isOfficeSpreadsheet(spreadsheetToken) {
|
||||
return officeSheetFileParentType
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
|
||||
@@ -25,9 +25,8 @@ 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 use either a legacy
|
||||
// prefix or the interleaved "OFL0X" marker and must upload with
|
||||
// "office_sheet_file".
|
||||
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
|
||||
// "local_office_" synthetic token and must upload with "office_sheet_file".
|
||||
func TestSheetMediaParentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -41,13 +40,6 @@ 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},
|
||||
}
|
||||
@@ -65,7 +57,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
|
||||
// (legacy prefix or interleaved OFL0X marker) as office_sheet_file.
|
||||
// (fake_office_-prefixed token) as office_sheet_file.
|
||||
func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -75,7 +67,6 @@ 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) {
|
||||
|
||||
@@ -16,7 +16,6 @@ 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"
|
||||
@@ -79,7 +78,7 @@ func newWikiDeleteSpaceRuntimeWithScopes(t *testing.T, as core.Identity, scopes
|
||||
|
||||
cfg := wikiTestConfig()
|
||||
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockWikiMoveTokenResolver{scopes: scopes}, nil)
|
||||
factory.Credential = newWikiTestCredentialProvider(cfg.AppID, &mockWikiMoveTokenResolver{scopes: scopes})
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +delete-space"}, cfg, as)
|
||||
runtime.Factory = factory
|
||||
|
||||
@@ -27,6 +27,18 @@ 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
|
||||
@@ -143,7 +155,7 @@ func newWikiMoveRuntimeWithScopes(t *testing.T, as core.Identity, scopes string)
|
||||
|
||||
cfg := wikiTestConfig()
|
||||
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockWikiMoveTokenResolver{scopes: scopes}, nil)
|
||||
factory.Credential = newWikiTestCredentialProvider(cfg.AppID, &mockWikiMoveTokenResolver{scopes: scopes})
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +move"}, cfg, as)
|
||||
runtime.Factory = factory
|
||||
|
||||
@@ -26,12 +26,13 @@ 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 nil, nil
|
||||
return &extcred.Account{AppID: f.appID}, nil
|
||||
}
|
||||
func (f *fakeExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return &extcred.Token{Value: f.token, Source: "fake"}, nil
|
||||
@@ -381,7 +382,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{token: "real-token"}},
|
||||
[]extcred.Provider{&fakeExtProvider{appID: "cli_test", token: "real-token"}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
h := &proxyHandler{
|
||||
@@ -501,7 +502,7 @@ func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
|
||||
upstreamHost := strings.TrimPrefix(upstream.URL, "https://")
|
||||
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{token: realToken}},
|
||||
[]extcred.Provider{&fakeExtProvider{appID: "cli_test", token: realToken}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
|
||||
@@ -16,16 +16,14 @@ metadata:
|
||||
|
||||
## 身份
|
||||
|
||||
按**日程归属**选身份:
|
||||
|
||||
- 查看/管理登录用户本人的日程 → `--as user`(默认,绝大多数场景)。
|
||||
- 查看/管理 bot 自己创建/拥有的日程 → `--as bot`
|
||||
日程操作默认使用 `--as user`(查看和管理当前用户的日程)。`--as bot` 只能访问 bot 自己的(空)日历,会拿到空结果——不要用 bot 身份查用户日程。
|
||||
|
||||
```bash
|
||||
# 用户本人日程 → user
|
||||
lark-cli calendar +agenda --as user
|
||||
# bot 自建或参与的日程 → bot
|
||||
# BAD — bot 身份查用户日程,返回空列表
|
||||
lark-cli calendar +agenda --as bot
|
||||
|
||||
# GOOD — user 身份查日程
|
||||
lark-cli calendar +agenda --as user
|
||||
```
|
||||
|
||||
## Shortcuts
|
||||
@@ -50,7 +48,7 @@ lark-cli calendar +agenda --as bot
|
||||
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
|
||||
```
|
||||
|
||||
日程描述统一使用 `description` 一个字段,按 **Markdown** 富文本处理。读取日程时 `description` 返回 Markdown 富文本(仅有纯文本描述时返回该纯文本);创建/更新日程时也通过 `--description` 传入 Markdown。
|
||||
读取日程时同时返回 `description`(纯文本)和 `description_rich`(**Markdown** 富文本)两个字段:`description` 存纯文本,`description_rich` 存富文本;仅有纯文本描述时,`description_rich` 会用该纯文本兜底填充(两字段值相同)。创建/更新日程时只传 `description_rich`(Markdown)。
|
||||
|
||||
### `+search-event` — 按关键词、时间范围和参会人搜索日程
|
||||
|
||||
@@ -190,8 +188,6 @@ 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)
|
||||
|
||||
@@ -32,19 +32,20 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
| `--summary <text>` | 否 | 日程标题。注意:标题中不应该出现时间、地点、人物信息 |
|
||||
| `--start <time>` | 是 | 开始时间(ISO 8601,如 `2026-03-12T14:00+08:00`) |
|
||||
| `--end <time>` | 是 | 结束时间(ISO 8601) |
|
||||
| `--description <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ` 到 `### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 ``(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL(直接粘贴裸链接,或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径` 或 `-`(stdin)读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。|
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀。bot 可作为合法参会人,无需剔除 |
|
||||
| `--description-rich <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ` 到 `### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 ``(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL(直接粘贴裸链接,或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径` 或 `-`(stdin)读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。|
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。示例值:"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
|
||||
> `--description` 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
|
||||
> `--description-rich` 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<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 命令)
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
|
||||
| 步骤 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description ...` | 更新原重复性日程的标题/描述等 |
|
||||
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description ...` (逐个) | 同步更新例外日程的对应字段 |
|
||||
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description-rich ...` | 更新原重复性日程的标题/描述等 |
|
||||
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description-rich ...` (逐个) | 同步更新例外日程的对应字段 |
|
||||
|
||||
> 理由:例外已脱离原重复性日程独立存在,不会自动继承原日程的更新。
|
||||
|
||||
|
||||
@@ -50,13 +50,12 @@ 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 以逗号分隔。**不要传入 bot 的 open_id**:bot 是虚拟身份,不占会议室席位、无会议室偏好,传入只会干扰推荐结果。 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 |
|
||||
| `--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` 对应的重复范围。
|
||||
|
||||
@@ -39,7 +39,6 @@ lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 参与人含 **bot**:无需为 bot 查询忙闲。bot 是虚拟身份,可并行多个会议、无忙闲语义,检查它没有意义。
|
||||
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
|
||||
- 参与人含**群组**:无需展开群组成员查询忙闲
|
||||
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**
|
||||
|
||||
@@ -45,7 +45,7 @@ lark-cli calendar +suggestion \
|
||||
| ------------------------------- | ----- | ------------------------------------------------------------------- |
|
||||
| `--start <time>` | 否 | 搜索区间开始时间(支持日期/ISO 8601等格式,默认**当前时间**) |
|
||||
| `--end <time>` | 否 | 搜索区间结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
||||
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户(`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔。**不要传入 bot 的 open_id**:bot 是虚拟身份,可并行多个会议、无忙闲语义,传入会干扰推荐时段的忙闲计算。 |
|
||||
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户(`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔 |
|
||||
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
|
||||
| `--duration-minutes <min>` | 否 | 会议时长(分钟)。优先使用用户显式指定的值,若未指定则尝试根据上下文推断,推断失败则不传 |
|
||||
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai`) |
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
lark-cli calendar +update \
|
||||
--event-id "<EVENT_ID>" \
|
||||
--summary "产品评审" \
|
||||
--description "评审需求范围、排期与风险" \
|
||||
--description-rich "评审需求范围、排期与风险" \
|
||||
--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 <markdown>` | 否 | 新日程描述,统一使用此字段,格式为 **Markdown**(加粗、斜体、下划线 `<u>...</u>`、删除线、链接 `[文本](url)`、标题 `# `~`### `(最多三级)、引用 `> `、有序/无序列表、GFM 表格 `\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`、以及图片 ``(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL(裸链接或 `[文本](url)`)会自动解析为内联文档,端上展示文档标题。支持 `@文件路径` 或 `-`(stdin)读取。仅在显式传入时更新;传空字符串 `""` 会清空描述。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。 |
|
||||
| `--description-rich <markdown>` | 否 | 新日程描述,统一使用此字段,格式为 **Markdown**(加粗、斜体、下划线 `<u>...</u>`、删除线、链接 `[文本](url)`、标题 `# `~`### `(最多三级)、引用 `> `、有序/无序列表、GFM 表格 `\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`、以及图片 ``(标准 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,18 +52,17 @@ lark-cli calendar +update \
|
||||
| `--notify` | 否 | 是否发送更新通知,默认 `true`。可用 `--notify=false` 静默更新 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
至少需要提供一个动作:`--summary`、`--description`、`--start/--end`、`--rrule`、`--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
至少需要提供一个动作:`--summary`、`--description-rich`、`--start/--end`、`--rrule`、`--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
|
||||
## 使用规则
|
||||
|
||||
- `--add-attendee-ids` 是**增量添加**,不是替换最终参与人列表。不要用它表达“只保留这些人”。
|
||||
- 对 `--summary`、`--description`,CLI 以“是否显式传入该 flag”判断是否更新,而不是以“值是否为空”判断;如果显式传入空字符串,会把对应字段清空。
|
||||
- 日程描述统一走 `--description`(按 Markdown 富文本处理)。
|
||||
- 对 `--summary`、`--description-rich`,CLI 以“是否显式传入该 flag”判断是否更新,而不是以“值是否为空”判断;如果显式传入空字符串,会把对应字段清空。
|
||||
- 日程描述统一走 `--description-rich`(Markdown)。`--description`(纯文本)已废弃并从帮助中隐藏。
|
||||
- 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<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) 执行。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-drive
|
||||
version: 1.0.0
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、查询权限设置、评论/权限/订阅、标题、版本、飞书文档密级标签(secure labels)和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本、飞书文档密级标签(secure labels)和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -27,7 +27,6 @@ metadata:
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要为指定飞书文档**设置 / 修改密级标签(secure label)**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置**,优先使用 `lark-cli drive +permission-get-setting`;它只读取目标自身设置,不递归审计文件夹子文档权限。裸 token 必须显式传 `--type`。
|
||||
- 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search` 或 `drive +move` 开始。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。
|
||||
@@ -121,7 +120,6 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
### 权限能力入口
|
||||
|
||||
- 用户要管理 Drive 文档/文件协作者、公开权限、授权当前应用访问文档,或处理 `permission.public.patch` 的 `91009` / `91010` / `91011` / `91012` 错误时,先读 [`lark-drive-permission-guide.md`](references/lark-drive-permission-guide.md)。
|
||||
- 用户要查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置,使用 [`+permission-get-setting`](references/lark-drive-permission-get-setting.md);如果要递归审计文件夹下子文档权限,再进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户只是没有访问权限并希望向 owner 申请访问,优先使用 [`+apply-permission`](references/lark-drive-apply-permission.md)。
|
||||
- 普通 scope、身份或登录问题仍按 [`lark-shared`](../lark-shared/SKILL.md) 处理;不要把租户安全策略、对外分享、密级拦截简单归类为缺 scope。
|
||||
|
||||
@@ -165,8 +163,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical token;wiki URL 会自动解包到底层文档。 |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 |
|
||||
| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create,真实写入需要 `--yes`。 |
|
||||
| [`+member-list`](references/lark-drive-member-list.md) | 查询 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。 |
|
||||
| [`+permission-get-setting`](references/lark-drive-permission-get-setting.md) | 查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置;支持 URL 或裸 token + `--type`;不递归读取文件夹子文档权限。 |
|
||||
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
|
||||
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
# drive +member-list(查询协作者/授权成员列表)
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli drive +member-list`。它读取 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# URL 自动推断 type
|
||||
lark-cli drive +member-list \
|
||||
--token 'https://example.feishu.cn/drive/folder/<folder_token>' \
|
||||
--as user --format json
|
||||
|
||||
# 查询附加字段
|
||||
lark-cli drive +member-list \
|
||||
--token '<token>' \
|
||||
--type docx \
|
||||
--fields 'name,type,external_label' \
|
||||
--as user --format json
|
||||
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 裸 token 或完整 URL。URL 路径支持 `/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`。 |
|
||||
| `--type` | 裸 token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`,CLI 会拒绝。 |
|
||||
| `--fields` | 否 | 默认不传。可取 `name` / `type` / `avatar` / `external_label`,支持逗号分隔;也可传 `*` 请求当前支持的所有附加字段。该参数只声明期望返回的字段,不授予字段级权限。 |
|
||||
| `--perm-type` | 否 | 仅 `--type wiki` 有效;取值 `container` / `single_page`。 |
|
||||
| `--dry-run` | 否 | 只打印请求,不调用 API。 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON 输出原样透传 API 的 `data` :
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"member_type": "openid",
|
||||
"member_id": "ou_xxx",
|
||||
"perm": "view",
|
||||
"perm_type": "container",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
"external_label": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`--format pretty` 会轻量展示成员 ID、成员类型、权限、wiki `perm_type` 和已返回的附加字段。机器读取优先使用 `--format json`。
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **身份支持**:`--as user` 和 `--as bot` 均可用;缺 scope 或目标权限时按统一 permission 错误路径处理。
|
||||
- **接口 scope**:查询成员列表需要 `docs:permission.member:retrieve`。
|
||||
- **fields 默认**:不传 `--fields` 时按官方 API 默认,不请求姓名、头像、外部标签等附加字段;需要时显式指定。
|
||||
- **字段级权限**:`--fields` 只控制请求哪些附加字段,不保证服务端一定返回。请求用户的 `name` / `avatar` 时,应用还需开通 `contact:user.base:readonly`(“获取用户基本信息”;已具备官方兼容的历史通讯录权限也可满足要求)。
|
||||
- **缺字段语义**:字段级权限或数据可见性不足时,接口仍可能成功,但会省略相应敏感字段。响应中缺少已请求字段表示“服务端未返回”,不能解释为字段值为空,也不能据此认定成员信息完整。
|
||||
- **folder 支持**:CLI 支持 `--type folder` 并会按需求发送 `type=folder`;部分环境的后端如果尚未放开 folder 枚举,可能返回 `99992402 field validation failed`。
|
||||
@@ -1,48 +0,0 @@
|
||||
# drive +permission-get-setting(查询权限设置)
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli drive +permission-get-setting`。它读取单个 Drive 资源自身的公开访问、分享、协作者管理、安全与评论权限设置,不递归读取文件夹中的子资源。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 通过 URL 自动推断 type
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token 'https://example.feishu.cn/drive/folder/<folder_token>' \
|
||||
--as user --format json
|
||||
|
||||
# 通过 bare token 显式指定 type
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<folder_token>' \
|
||||
--type folder \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | bare token 或完整 URL。URL 路径支持 `/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`。 |
|
||||
| `--type` | bare token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`,CLI 会拒绝。 |
|
||||
| `--dry-run` | 否 | 只打印请求,不调用 API。 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON 输出中的 `data.permission_public` 是目标当前的权限设置;服务端未返回该字段时,命令会报响应结构错误,而不会把其他字段伪装成权限设置。
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"permission_public": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`--format pretty` 会展示完整的 `permission_public` 对象,包括服务端将来新增的字段。
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **身份支持**:`--as user` 和 `--as bot` 均可用。
|
||||
- **所需 scope**:`docs:permission.setting:read`。
|
||||
- **单目标读取**:命令只读取 `--token` 指向资源自身的权限设置;`--type folder` 不会递归读取子资源。
|
||||
@@ -26,16 +26,11 @@
|
||||
> **`--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` |
|
||||
@@ -222,7 +217,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` 和下一页命令。用户明确要"全部 / 全量 / 继续翻"时继续;标题词 + 正文词联合搜索尚未找到足够的有效 Top N 候选时,按上文规则最多检查 3 页。其他场景单轮翻页上限 5 页。
|
||||
- **分页策略**:默认只返回第一页,并说明 `has_more` 和下一页命令。只有用户明确要"全部 / 全量 / 继续翻"才继续。单轮翻页上限 5 页。
|
||||
- **原始返回**:用户要求"原始数据"、"接口返回"时用 `--format json`,不做客户端精确过滤或摘要重写。
|
||||
|
||||
## 权限
|
||||
|
||||
@@ -28,7 +28,7 @@ lark-cli drive +secure-label-list --page-size 10 --lang zh
|
||||
```bash
|
||||
lark-cli drive +secure-label-update \
|
||||
--token "https://example.feishu.cn/docx/doxcnxxxx" \
|
||||
--label-id '<label-id>' # replace $LABEL_ID before running
|
||||
--label-id "7217780879644737539"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
lark-cli drive +inspect --url '<url>' --as user --format json
|
||||
```
|
||||
|
||||
`drive +inspect` 支持 Drive folder,并且是受支持 Drive URL 的统一解析入口。对文件夹自身权限设置,先通过 `+inspect` 解析 URL,或直接使用 `drive +permission-get-setting --token '<folder_url>'`;传 bare folder token 时必须显式传 `--type folder`。
|
||||
|
||||
`/wiki/space/<space_id>` URL 是 Wiki space 范围,不要用 `drive +inspect` 当作单文档解析;直接提取 `space_id` 后进入 `DISCOVER_TARGETS`。
|
||||
|
||||
## 目标发现
|
||||
@@ -27,16 +25,16 @@ lark-cli drive +inspect --url '<url>' --as user --format json
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json # replace $SPACE_ID before running
|
||||
--as user --format json
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --parent-node-token '<node_token>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json # replace $SPACE_ID before running
|
||||
--as user --format json
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-token '<PAGE_TOKEN>' --page-size 50 \
|
||||
--as user --format json # replace $SPACE_ID before running
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
解析返回时使用 `data.nodes`,不要读取顶层 `items`。`--page-limit 0` 表示当前层分页不设页数上限;`--page-all` 只覆盖当前 `space-id` / `parent-node-token` 范围内的分页,不会递归子节点。节点 `has_child=true` 时,必须继续以该节点的 `node_token` 作为 `--parent-node-token` 递归读取。
|
||||
@@ -63,42 +61,14 @@ lark-cli drive metas batch_query \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
读取权限设置:
|
||||
读取 public permission:
|
||||
|
||||
```bash
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<url-or-token>' --type '<type>' \
|
||||
lark-cli drive permission.public get \
|
||||
--params '{"token":"<token>","type":"<type>"}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
裸 folder token 必须显式传 `--type folder`:
|
||||
|
||||
```bash
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<folder_token>' --type folder \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
通过 URL 读取权限设置时可以省略 `--type`:
|
||||
|
||||
```bash
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<url>' \
|
||||
--as user --format json # replace $LARK_DRIVE_URL before running
|
||||
```
|
||||
|
||||
按需读取直接协作者/授权成员列表:
|
||||
|
||||
```bash
|
||||
lark-cli drive +member-list \
|
||||
--token '<token_or_url>' \
|
||||
--type '<type>' \
|
||||
--fields 'name,type,external_label' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
`--fields` 默认不传;只有需要名称、协作者类型、头像或外部标签时才显式传。它只声明期望返回的字段,不授予字段级权限:请求用户的 `name` / `avatar` 时还需 `contact:user.base:readonly`(“获取用户基本信息”)。字段权限或数据可见性不足时,接口仍可能成功但省略相应字段;缺字段不能解释为空值。
|
||||
|
||||
按需读取访问统计:
|
||||
|
||||
```bash
|
||||
@@ -190,9 +160,9 @@ lark-cli drive +secure-label-list \
|
||||
```bash
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<url>' \
|
||||
--label-id '<label-id>' --as user --format json # replace $LABEL_ID before running
|
||||
--label-id '<label-id>' --as user --format json
|
||||
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<bare-token>' --type '<type>' \
|
||||
--label-id '<label-id>' --as user --format json # replace $LABEL_ID before running
|
||||
--label-id '<label-id>' --as user --format json
|
||||
```
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
- 多目标明确列表默认输出逐目标诊断摘要;不要因为目标数大于 1 就套用容器递归发现报告。
|
||||
- 用户可见结论默认跟随用户当前语言。用户用中文提问时输出中文,用户用英文提问时输出英文;混合语言时跟随主要语言。
|
||||
- 单目标公开性判断默认输出业务表达,不直接展示 `link_share_entity`、`external_access_entity`、`external_access` 等底层字段名;只有用户要求 raw evidence、排障,或完整清单 / artifact 场景才展示底层字段。
|
||||
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“目标公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。优先按实际返回字段解释公开访问、分享、协作者管理、安全与评论设置;复制内容、创建副本、打印、下载等字段只有在当前 CLI schema 和实际响应返回时才可判断。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
|
||||
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“文档公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。它在官方语义中包含链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论;具体可判断字段以当前 CLI schema 和实际响应为准。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
|
||||
- 容器目标默认输出安全诊断报告摘要:一句话结论、覆盖情况、风险分级、优先处理对象、建议下一步和剩余限制。
|
||||
- 容器目标不要把风险按数量机械排序;外部公开、允许对外分享、缺失密级标签优先于复制 / 下载 / 评论这类依赖策略的候选项。
|
||||
- 用户没有提供明确 policy 时,使用“候选风险 / 待复核 / 待策略确认”,不要写“违规 / 已泄露 / 已外部访问”。
|
||||
@@ -36,7 +36,7 @@
|
||||
- 当摘要未展示全部风险对象时,必须明确“完整清单包含 <count> 条”,并提供生成 Markdown / CSV / 飞书文档风险清单或整改 dry-run 的下一步。
|
||||
- 只要发现需要处理的对象,最终回复必须给出可执行下一步 CTA。不能因为默认只读,就只报告风险后结束。
|
||||
- 完整风险清单是后续治理选择的输入;Markdown / CSV / 飞书文档报告必须使用同一套字段和稳定 `risk_id`。
|
||||
- 写入前必须使用确认模板;权限申请、目标公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
|
||||
- 写入前必须使用确认模板;权限申请、文档公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
|
||||
- 最终回复必须包含已完成事项、验证结果和剩余限制;异步权限申请审批不能表述为已完成授权。
|
||||
|
||||
## Semantic Rendering
|
||||
@@ -75,7 +75,7 @@
|
||||
| `lock_switch=true` | `lock_state=locked_not_inheriting` | 已限制权限,不再继承父级页面权限 | The node is locked and no longer inherits parent-page permissions |
|
||||
| `lock_switch=false` | `lock_state=not_locked_or_inheriting` | 未限制权限,可能继承父级页面权限 | The node is not locked and may inherit parent-page permissions |
|
||||
| field absent / unsupported | `<state>=unknown` | 当前 schema 未返回,无法判断 | The current schema did not return this field, so it is unknown |
|
||||
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前目标公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers the target's current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
|
||||
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前文档公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
|
||||
| `sec_label_name` missing | `sec_label=missing` | 缺少密级标签 | Security label is missing |
|
||||
|
||||
## 定位与治理动作
|
||||
@@ -165,7 +165,7 @@ Evidence fields:
|
||||
|
||||
覆盖情况:
|
||||
- 用户提供目标:<input_target_count>;成功解析:<resolved_count>
|
||||
- 成功读取目标公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
|
||||
- 成功读取文档公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
|
||||
|
||||
逐目标结果(1-10 个目标默认全部展示;超过 10 个时按 `摘要清单展开规则` 展示,并提示生成完整风险清单):
|
||||
|
||||
@@ -233,7 +233,7 @@ URL:<url-or-token-if-url-unavailable>
|
||||
|
||||
覆盖情况:
|
||||
- 当前身份可见目标:<visible_count>
|
||||
- 已成功检查目标公共访问和协作权限设置:<permission_checked_count>
|
||||
- 已成功检查文档公共访问和协作权限设置:<permission_checked_count>
|
||||
- 读取失败 / 已删除 / 无权限:<failed_count>
|
||||
- 未覆盖能力:<collaborator_list / inheritance / audit_log / view_records / none>
|
||||
|
||||
@@ -355,8 +355,8 @@ Agent 必须回复:
|
||||
- 字段变更:
|
||||
- <risk_id> <path> (<url-or-token>): <field> <old> -> <new>
|
||||
- 跳过项:<unsupported / no manage_public / unsupported type / missing policy>
|
||||
- 验证方式:执行后重新读取 <元数据 / 目标公共访问和协作权限设置>
|
||||
- 有限回滚范围:<目标公共访问和协作权限设置快照字段 / 不适用>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚范围:<文档公共访问和协作权限设置快照字段 / 不适用>
|
||||
|
||||
请确认是否进入写入确认。
|
||||
```
|
||||
@@ -407,8 +407,8 @@ Agent 必须回复:
|
||||
- 风险:<risk_level>
|
||||
- 字段变更:
|
||||
- <field>: <old> -> <new>
|
||||
- 验证方式:执行后重新读取 <元数据 / 目标公共访问和协作权限设置>
|
||||
- 有限回滚材料:<目标公共访问和协作权限设置快照 / 不适用>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
|
||||
请确认是否执行。
|
||||
```
|
||||
@@ -419,6 +419,6 @@ Agent 必须回复:
|
||||
已完成:<read checks / writes>
|
||||
验证:<fresh read result or async permission-request approval note>
|
||||
清单状态:<risk_id status updates / not applicable>
|
||||
回滚材料:<目标公共访问和协作权限设置快照 / 不适用>
|
||||
回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
剩余限制:<unsupported_checks / partial facts / approvals>
|
||||
```
|
||||
|
||||
@@ -38,11 +38,11 @@ Risk / Structure: `R2` / `S2`
|
||||
- 目录组织、迁移、归档或清理;这类需求应使用知识整理 workflow。
|
||||
- 内容审查、过期内容判断或知识质量评分。
|
||||
- backup owner 补充、部门 / 项目负责人绑定、协作者创建 / 撤销、成员列表审计;本 workflow 只支持把 owner 转移给每个目标明确指定的新 owner,不建模 backup owner 或负责人绑定关系。
|
||||
- 文件夹自身公开权限审计或修复。文件夹自身权限设置可以用 `drive +permission-get-setting` 读取;写入是否支持必须以运行时 schema 和明确需求为准,不能猜测执行 `patch type=folder`。
|
||||
- 文件夹自身公开权限审计或修复。`drive permission.public get` / `patch` 不支持 `type=folder`;必须记录到 `unsupported_checks`,然后继续读取文件夹下其他支持的文档事实。
|
||||
- 当前身份无法枚举到的不可见文档的完整发现;只能处理已发现目标,或用户显式提供的 URL / token。
|
||||
- 未按范围确认的批量写入。
|
||||
|
||||
协作者列表读取只覆盖当前目标的直接协作者/授权成员:可使用 `drive +member-list` 。
|
||||
不要声称已完成协作者列表验证:当前 CLI surface 没有 `permission.members list` shortcut。
|
||||
|
||||
## Progressive Load Map
|
||||
|
||||
@@ -53,7 +53,7 @@ Risk / Structure: `R2` / `S2`
|
||||
| `PARSE_INTENT` | 本文件、[`lark-drive-workflow.md`](lark-drive-workflow.md)、[`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) |
|
||||
| `TARGET_INSPECT` | [`lark-drive-inspect.md`](lark-drive-inspect.md) |
|
||||
| `DISCOVER_TARGETS` | 容器范围时读取 [`../../lark-wiki/references/lark-wiki-node-list.md`](../../lark-wiki/references/lark-wiki-node-list.md) 或 [`lark-drive-files-list.md`](lark-drive-files-list.md) |
|
||||
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及权限设置读取时使用 `drive +permission-get-setting`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get` 和 `lark-cli schema drive.file.view_records.list` |
|
||||
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及公开权限时再读取 `lark-cli schema drive.permission.public.get`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get` 和 `lark-cli schema drive.file.view_records.list` |
|
||||
| `RISK_ASSESS` | 本文件的 `Risk Classification` |
|
||||
| `EXEC_CONFIRM` | 只为用户选择的动作读取 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)、[`lark-drive-secure-label.md`](lark-drive-secure-label.md),或 `lark-cli schema drive.permission.public.patch` / `lark-cli schema drive.permission.members.transfer_owner`;需要确认模板时读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) |
|
||||
| `EXECUTE` | 复用 `EXEC_CONFIRM` 已加载且已确认的写命令上下文 |
|
||||
@@ -76,9 +76,9 @@ Risk / Structure: `R2` / `S2`
|
||||
| State | Protocol Step | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|
||||
|-------|---------------|---------------|--------------------|---------------|------------|
|
||||
| `PARSE_INTENT` | `route` / `scope` | 解析 intent、target scope、desired policy,以及只读审计、单目标公开性判断、权限申请、owner 转移还是修复模式;单目标公开性判断设置 `intent=public_exposure_check`、`target_scope=single_resource` | 范围确认;如果缺少目标、新 owner 或期望动作,只问一个澄清问题 | 缺少 target / new owner / action,或容器范围需要用户确认时为 `true` | `TARGET_INSPECT` |
|
||||
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder;Drive folder 直接从 URL 路径或显式 `type=folder` 解析,不调用 `drive +inspect`;保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
|
||||
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder;保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
|
||||
| `DISCOVER_TARGETS` | `scope` / `read` | 对 Wiki space / node 或 Drive folder 递归只读枚举,归一化为 `discovered_targets`;记录 `discovery_blockers` | 发现进度和覆盖摘要;不展示内部 cursor/token,除非用户要求 | 除非发现范围无法确认或全部被阻断,否则为 `false` | `FACT_READ` |
|
||||
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的文件、文件夹或云文档目标执行 `drive +permission-get-setting` 读取自身权限设置;当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type,只补读目标公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
|
||||
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的非 folder 目标执行 `drive permission.public get`;当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type,只补读文档公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
|
||||
| `RISK_ASSESS` | `assess/plan` | 对每个可审计目标生成 `per_target_permission_assessment` 并分类证据;如用户提供 policy,则对照 policy;`public_exposure_check + single_resource` 只渲染单目标结论,不生成 `risk_id`;owner 转移路径生成 `owner_transfer_candidates` / `owner_transfer_plan`;治理路径构建可定位风险清单、访问复核清单、dry-run 整改计划或候选修复计划,完整清单必须生成稳定 `risk_id` | 带 priority、URL、risk_id、owner、sec_label 的 findings、confidence、review items、建议动作和下一步 CTA;单目标公开性判断只输出结论和关键字段 | 治理路径为 `true`,单目标公开性判断为 `false` | `EXEC_CONFIRM` or `DONE` |
|
||||
| `EXEC_CONFIRM` | `confirm` | 展示准确写入范围、command family、target count、risk、verification method | 确认请求 | `true` | `EXECUTE` or `DONE` |
|
||||
| `EXECUTE` | `execute` | 只执行 `Command Map` 中已确认的写入 | 进度 / 结果摘要 | 除非被阻断,否则为 `false` | `VERIFY` |
|
||||
@@ -91,23 +91,21 @@ Risk / Structure: `R2` / `S2`
|
||||
|
||||
| State | Allowed Command Families | Purpose |
|
||||
|-------|--------------------------|---------|
|
||||
| `TARGET_INSPECT` | `drive +inspect` | 解析非 folder URL、type、canonical token、title 和 wiki unwrap data;Drive folder 不支持 `+inspect`,必须从 URL 路径或显式 `type=folder` 直接解析 |
|
||||
| `TARGET_INSPECT` | `drive +inspect` | 解析 URL、type、canonical token、title 和 wiki unwrap data |
|
||||
| `DISCOVER_TARGETS` | `wiki +node-list` | 递归发现 Wiki space / node 下当前身份可见的节点 |
|
||||
| `DISCOVER_TARGETS` | `drive files list` | 递归发现 Drive folder 下当前身份可见的文件和子文件夹 |
|
||||
| `FACT_READ` | `drive metas batch_query` | 读取 title、URL、owner 和 secure-label metadata |
|
||||
| `FACT_READ` | `drive permission.public get` | 读取支持类型的文档公共访问和协作权限设置,包括链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论 |
|
||||
| `FACT_READ` | `drive +member-list` | 读取用户显式要求的单目标直接协作者/授权成员列表;不代表完整继承链或历史权限审计 |
|
||||
| `FACT_READ` | `drive +permission-get-setting` | 读取支持类型的文件、文件夹或云文档自身权限设置,包括公开访问、分享、协作者管理、安全与评论 |
|
||||
| `FACT_READ` | `drive file.statistics get` | 在用户要求活跃度、闲置暴露、生命周期或访问复核时读取文件访问统计 |
|
||||
| `FACT_READ` | `drive file.view_records list` | 在用户要求最近访问人、访问复核或低活跃证据时读取访问记录 |
|
||||
| `EXEC_CONFIRM` | `drive +secure-label-list` | 提议 label update 前解析可用 secure-label IDs |
|
||||
| `EXEC_CONFIRM` | `drive permission.members auth` | 目标公共访问和协作权限设置修改前检查 `action=manage_public` |
|
||||
| `EXEC_CONFIRM` | `drive permission.members auth` | 文档公共访问和协作权限设置修改前检查 `action=manage_public` |
|
||||
| `EXEC_CONFIRM` | `lark-cli schema drive.permission.members.transfer_owner` | owner 转移前读取当前字段、支持类型和高风险写入门禁 |
|
||||
| `EXECUTE` | `drive +apply-permission` | 向 owner 提交 view/edit access request;只允许单目标、小列表或已明确确认的候选列表逐个执行 |
|
||||
| `EXECUTE` | `drive permission.public patch` | 修改已确认的 public/link settings;必须传 `--yes` |
|
||||
| `EXECUTE` | `drive permission.members transfer_owner` | 转移已确认目标的 owner;必须传 `--yes` |
|
||||
| `EXECUTE` | `drive +secure-label-update` | 设置已确认的 secure-label ID |
|
||||
| `VERIFY` | `drive metas batch_query`, `drive +permission-get-setting` | 验证支持的 metadata,包括 owner、secure-label 和目标公共访问与协作权限设置变更;权限申请只能表述为已发起 |
|
||||
| `VERIFY` | `drive metas batch_query`, `drive permission.public get` | 验证支持的 metadata,包括 owner、secure-label 和文档公共访问与协作权限设置变更;权限申请只能表述为已发起 |
|
||||
|
||||
## Command Patterns
|
||||
|
||||
@@ -121,9 +119,9 @@ Risk / Structure: `R2` / `S2`
|
||||
|
||||
1. "所有文档"只表示当前身份在确认范围内可枚举到的文档。不可见、无权限、API 不返回或工具预算不足的部分必须进入 `discovery_blockers` 或 `unsupported_checks`。
|
||||
2. 发现阶段必须生成稳定 `path`。不要只保存 title;同名文档必须能通过 path 或 token 区分。
|
||||
3. 权限设置读取使用 `drive +permission-get-setting`,目标类型包括 `doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides`、`folder`;未来新增类型以 shortcut 和 OpenAPI 元数据为准。
|
||||
3. 只把 `drive.permission.public.get` 当前 schema 支持的类型加入公开权限可审计目标。已知支持包括 `doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides`;未来新增类型以运行时 schema 为准。
|
||||
4. `minutes` 只能作为 `partial_public_permission` 目标:可读取 / 修改公开权限和 owner 转移能力以运行时 schema 为准,但 `drive metas batch_query` 当前不支持 `minutes`,URL、owner、密级等 metadata 可能进入 `unsupported_checks`。
|
||||
5. `folder` 作为递归容器时先枚举子资源;如用户明确要查询文件夹自身权限设置,可对该文件夹单独执行 `drive +permission-get-setting --token <folder_token> --type folder`。不要执行 raw `permission.public patch type=folder`,除非 schema 和需求都明确支持。`shortcut`、`catalog` 或缺少 stable token/type 的条目必须记录为 unsupported,除非后续 API 明确解析出支持目标。
|
||||
5. `folder` 只作为递归容器,不执行 `permission.public get` / `patch`。如果用户明确要求 owner 转移且 schema 支持 `folder`,必须按 owner-transfer 写入规则单独确认。`shortcut`、`catalog` 或缺少 stable token/type 的条目必须记录为 unsupported,除非后续 API 明确解析出支持目标。
|
||||
6. 对大范围目标输出进度时,只展示已扫描容器数、已发现目标数、已审计目标数、剩余队列或 blocker;不要默认展示内部 page token / cursor。
|
||||
|
||||
Wiki space / node 发现:
|
||||
@@ -135,7 +133,7 @@ Wiki space / node 发现:
|
||||
|
||||
Drive folder 发现:
|
||||
|
||||
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`。默认继续枚举其子文档;只有用户明确要求文件夹自身权限设置时,才额外调用 `drive +permission-get-setting --token <folder_token> --type folder` 读取该文件夹自身设置。
|
||||
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`。文件夹自身公开权限不支持;继续枚举其子文档。
|
||||
2. 按 [`lark-drive-files-list.md`](lark-drive-files-list.md) 递归处理 `data.files`、`has_more` 和 `next_page_token`。不要把第一页数量当作完整范围。
|
||||
3. 只对返回项中的 `folder` 继续递归;对子文档按 `type + token` 归一化为 `discovered_targets`。
|
||||
4. 如果某个目录分页失败、无 continuation token、权限不足或 API 报错,只阻断该目录分支,并在 `discovery_blockers` 中记录;继续处理其他可枚举分支。
|
||||
@@ -143,11 +141,11 @@ Drive folder 发现:
|
||||
## Fact Read Rules
|
||||
|
||||
1. `drive metas batch_query` 单次最多 200 个 `request_docs`;当 `targets` 或 `discovered_targets` 超过 200 个时,必须分批读取并合并结果。
|
||||
2. `drive +permission-get-setting` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks` 或 `partial`,不要阻断其他目标。
|
||||
2. `drive permission.public get` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks` 或 `partial`,不要阻断其他目标。
|
||||
3. 对 Wiki 发现目标,公开权限读取优先使用 `type=wiki` + `node_token`;metadata 可使用 `obj_type` + `obj_token` 补充 title、owner、URL 和 `sec_label_name`。
|
||||
4. 当 intent 是 `list_permission_settings` 时,只输出权限设置清单和覆盖限制,不主动生成修复计划。
|
||||
5. 单目标、多目标明确列表和容器发现目标都必须复用同一套逐目标事实读取与语义归一逻辑;差异只体现在目标来源、coverage summary 和输出聚合。
|
||||
6. `permission_public` 用户可见含义是“目标公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed`;`manage_collaborator_entity`、`copy_entity`、`lock_switch` 等字段缺失时标记为 unknown,不要伪造;未识别字段保留在 raw evidence / partial note 中。
|
||||
6. `permission_public` 用户可见含义是“文档公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed`;`manage_collaborator_entity`、`copy_entity`、`lock_switch` 等字段缺失时标记为 unknown,不要伪造;未识别字段保留在 raw evidence / partial note 中。
|
||||
7. `drive file.statistics get` 和 `drive file.view_records list` 只在用户要求最近访问、活跃度、闲置暴露、访问复核,或用户提供的 policy 明确依赖活跃度时执行;不要为普通权限审计默认读取访问记录。
|
||||
8. 访问统计 / 访问记录当前只对 `doc`、`docx`、`sheet`、`bitable`、`mindnote`、`wiki`、`file` 作为支持类型处理。其他类型必须进入 `unsupported_checks`,不能推断活跃度。
|
||||
9. `view_records` 是访问证据,不是权限列表。没有返回访问记录只能表述为“未获得最近访问证据”或“低活跃候选”,不能表述为“无人有权限”。
|
||||
@@ -164,17 +162,17 @@ Drive folder 发现:
|
||||
- `PolicyReview`:复制、创建副本、打印、下载、评论等依赖 policy 的设置;没有明确 policy 时不要称为高风险。
|
||||
- `Unknown`:读取失败、已删除、无权限、API 不支持、协作者名单 / 继承链 / DLP / AI 索引 / 审计日志未覆盖。
|
||||
|
||||
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前目标公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
|
||||
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前文档公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
|
||||
|
||||
`AI 检索暴露候选风险` 只是基于权限和标签的代理标签。除非另有工具明确返回索引状态,否则不要声称某个文档已经被 Agent、Copilot 或 RAG 索引。
|
||||
|
||||
## 写入规则
|
||||
|
||||
- 目标公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
|
||||
- 文档公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
|
||||
- 如果 `manage_public_auth.auth_result=false`,禁止 patch。告诉用户需要具备 manage-public 权限的用户,或由 owner 操作。
|
||||
- 权限设置读取使用 `drive +permission-get-setting`;裸 token 必须传 `--type`,URL 可以自动推断。写入仍使用 `drive permission.public patch`,只 patch 已解析且 schema 明确支持的类型和字段,不要把读取支持的 `folder` 自动外推为可写入。
|
||||
- `drive permission.public get` 只用于 `drive +inspect` 或 `DISCOVER_TARGETS` 可解析且运行时 schema 支持的目标类型;类型集合不要硬编码,执行时以 `lark-cli schema drive.permission.public.get` 为准。
|
||||
- 不要 patch 已解析类型不支持的字段。对于 wiki 目标,必须省略 schema 明确标注为 wiki 不支持的字段。
|
||||
- 不要在同一个写入确认中合并密级标签更新和目标公共访问与协作权限设置修改;必须分别确认。
|
||||
- 不要在同一个写入确认中合并密级标签更新和文档公共访问与协作权限设置修改;必须分别确认。
|
||||
- `drive +apply-permission` 默认不批量执行;每次调用都会向 owner 发送通知。
|
||||
- `permission_request_candidates` 可以来自用户直接提供的目标、明确列表或容器发现目标;只要能构造 token、type、权限类型和申请理由,就可以进入候选。不要因为目标不在 `discovered_targets` 中而拒绝单目标 / 小列表权限申请。
|
||||
- 容器范围内的"统一申请权限"必须先产出 `permission_request_candidates`。未展示候选目标、数量、权限类型和 owner 通知影响前,禁止调用 `drive +apply-permission`。
|
||||
@@ -184,8 +182,8 @@ Drive folder 发现:
|
||||
- 批量 owner 转移必须逐个顺序执行;失败项进入结果清单,不要重复执行已成功目标。`remove_old_owner=true` 或 `old_owner_perm` 降权必须单独在确认中高亮。
|
||||
- 用户要求“生成整改方案 / dry-run / 先看看会改什么”时,只生成 `remediation_plan`,不执行任何写命令。dry-run 必须包含 target count、field changes、跳过原因、验证方式和有限回滚范围。
|
||||
- 用户基于完整风险清单选择对象时,必须先解析 `risk_id`、风险分组、URL 或 artifact 中 `selected=true` 的行,生成 `selected_risk_items`。无法匹配到当前 `risk_manifest` 的选择必须要求用户重新确认或重新读取清单。
|
||||
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive +permission-get-setting`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
|
||||
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于目标公共访问和协作权限设置字段的有限回滚说明,不覆盖协作者、owner、继承权限或密级标签。
|
||||
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive permission.public get`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
|
||||
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于文档公共访问和协作权限设置字段的有限回滚说明,不覆盖协作者、owner、继承权限或密级标签。
|
||||
- 如果用户要求批量收紧权限,必须按风险分层和目标顺序逐个执行;失败项进入结果清单,不要因为单个失败而重复执行已成功目标。
|
||||
- 遇到 secure-label downgrade error `1063013` 时,停止重试,并告诉用户需要在文档 UI 中完成审批。
|
||||
|
||||
@@ -196,7 +194,7 @@ Drive folder 发现:
|
||||
- `drive permission.members create` 可创建协作者权限,但当前 workflow 不做协作者 grant / update / revoke;未来需要单独定义授权对象解析、最小权限、确认模板和验证方式。
|
||||
- backup owner、部门 / 项目负责人绑定没有当前 workflow 可执行写入面;如用户要落地为 owner 转移,必须先给出明确目标和新 owner,并走本 workflow 的 owner-transfer 确认。
|
||||
- `wiki +member-list` 可作为 Wiki space 成员治理的读侧事实来源;当前 workflow 只治理文档 / 节点 / 文件夹下可发现文档的权限,不做 space member governance。
|
||||
- `drive +member-list` 可读取单目标直接协作者/授权成员;当前 CLI 仍没有完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
|
||||
- 当前 CLI 没有 `permission.members list`、完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
|
||||
|
||||
## 输出策略
|
||||
|
||||
|
||||
@@ -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, or handling _notice JSON."
|
||||
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."
|
||||
---
|
||||
|
||||
# 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,6 +126,18 @@ 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` 等)。
|
||||
|
||||
@@ -75,17 +75,15 @@ metadata:
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**本表只定位「场景 → 用哪条命令、读哪份文档」。参数以「执行前必做」里对应的文档和 `lark-cli slides +<verb> --help` 为准,不要凭记忆或按别的命令类比补参数。**
|
||||
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`lark-slides-create.md`、`slides +create` |
|
||||
| 用户要求使用模板,或提供 PPTX 文件要求修改、美化 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 用户要求使用模板 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,用 shortcut 回读全文 XML 或读取单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `slides +xml-get`、`xml_presentation.slide.get`、`lark-slides-xml-presentations-get.md` |
|
||||
| 查看或回滚历史版本 | 先用 `+history-list` 找 `history_version_id`,再 `+history-revert`,必要时 `+history-revert-status` 轮询 | [`lark-slides-history.md`](references/lark-slides-history.md) |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面,一次不超过 10 页 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`、`lark-slides-media-upload.md`,或 `+create --slides` 的 XML 里写 `<img src="@./path">` 占位符 |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`、`lark-slides-media-upload.md`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 绘制图表 | 原生图表(柱状、条形、折线、面积、饼(环)、雷达、组合图)用 `<chart>`,其他(漏斗图、金字塔图、象限图、矩阵图等)用 `<shape>` + `<line>` 模拟 | `xml-schema-quick-ref.md`、`slides_chart_demo.xml` |
|
||||
| 绘制表格 | 优先用 `rect` 和 `text` 模拟,其他用 `<table>` | `xml-schema-quick-ref.md` |
|
||||
| 使用图标 | 禁止盲猜 iconType,必须先检索 IconPark,再写 `<icon iconType="...">`,图标必须填充颜色并和背景有足够对比,禁止使用 emoji 图标 | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
@@ -143,9 +141,9 @@ lark-cli auth login --domain slides
|
||||
- [asset-planning.md](references/asset-planning.md)(新建 / 大幅改写)
|
||||
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后)
|
||||
|
||||
调用相关命令前必须读取相关的文档以了解命令的使用方式:
|
||||
按需再读:
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)、[`lark-slides-xml-presentation-slide-create.md`](references/lark-slides-xml-presentation-slide-create.md)(逐页添加)
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 阅读:[`lark-slides-xml-presentations-get.md`](references/lark-slides-xml-presentations-get.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 历史版本:[`lark-slides-history.md`](references/lark-slides-history.md)
|
||||
@@ -191,6 +189,20 @@ lark-cli auth login --domain slides
|
||||
- 不要在任何位置使用 emoji 图标。
|
||||
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
### 生成流程
|
||||
|
||||
```text
|
||||
@@ -208,18 +220,17 @@ Step 2: 生成大纲 → 写入 slide_plan.json
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成替代图片,不要留空
|
||||
- 读 lark-slides-create.md 定一步创建还是两步创建,并据此构造 `slides +create`;两步创建再读 lark-slides-xml-presentation-slide-create.md 逐页添加
|
||||
- 图片按 lark-slides-media-upload.md 处理;复杂 XML、转义和 3350001 排查按 troubleshooting.md 执行
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 `slides +xml-get --presentation <xml_presentation_id>` 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 创建完成后,必须用 `slides +xml-get` 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 没问题 → 交付:使用 NotifyHuman 工具交付 PPT 链接
|
||||
```
|
||||
|
||||
### jq 命令模板(编辑已有 PPT 时使用)
|
||||
|
||||
以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
|
||||
```bash
|
||||
# 追加到末尾
|
||||
@@ -302,9 +313,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT,可选一步添加页面 |
|
||||
| [`+xml-get`](references/lark-slides-xml-presentations-get.md) | 读取全文 XML,用 `--presentation` 指定演示文稿的 `xml_presentation_id`,用 `--output` 把 XML 存到本地文件(必须是 CWD 内的相对路径,如 `.lark-slides/plan/<deck>/readback.xml`) |
|
||||
| [`+screenshot`](references/lark-slides-screenshot.md) | 把幻灯片页面截图保存为本地图片,用 `--slide-number` 指定页号(从 1 开始,多页重复传入,一次最多 10 页),用 `--output-dir` 指定保存目录(必须是 CWD 内的相对路径,默认 `.lark-slides/screenshots`),失败时降级到 XML 回读等非截图检查 |
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+xml-get`](references/lark-slides-xml-presentations-get.md) | 读取全文 XML 并保存到本地文件,避免终端输出被截断 |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
@@ -321,12 +331,12 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:新建演示文稿用 `slides +create`,一步创建还是两步创建按 [`lark-slides-create.md`](references/lark-slides-create.md) 判断
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传,或在 `+create --slides` 的 XML 里写 `<img src="@./path">` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
@@ -3,22 +3,13 @@
|
||||
|
||||
创建一个新的飞书幻灯片演示文稿,可选一步添加页面内容。
|
||||
|
||||
提交源必须是直接生成的单页 `<slide>` XML。禁止从完整 `<presentation>` XML 解析、拆分、重序列化出 slide 数组再提交。
|
||||
- 禁止:从完整 <presentation> XML 解析/拆分/重序列化生成提交 payload。
|
||||
- 推荐:提交源直接就是单页 <slide> XML;+create --slides 只接受已经人工/程序直接生成的 slide 数组,不接受由
|
||||
presentation 动态拆出来的数组。
|
||||
|
||||
本命令只从零创建演示文稿,没有导入本地 PPT 文件的参数。要把已有 PPTX 变成 Slides,用 `drive +import --file <x.pptx> --type slides`,再在导入结果上编辑,流程见 [lark-slides-pptx-template-workflows.md](lark-slides-pptx-template-workflows.md)。
|
||||
- 最稳:复杂 deck 默认空 deck + 单页 slide create,每次只提交一个 <slide>。
|
||||
|
||||
## 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 [`xml_presentation.slide create`](lark-slides-xml-presentation-slide-create.md) 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 [`xml_presentation.slide create`](lark-slides-xml-presentation-slide-create.md),必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
- 注意:复杂 XML 不适合直接塞命令行,中文、引号、特殊字符较多时,直接拼接 --slides 容易发生 shell 转义或截断。建议将每页 XML 保存为独立文件,使用 `jq --rawfile` 组装 JSON 数组,避免手动处理 XML 引号和换行。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -194,13 +194,14 @@
|
||||
<xs:simpleType name="FontSizeType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
字体大小, 浮点数, 范围 [1, 4000], 单位px
|
||||
示例:10, 10.5, 12, 14, 16, 18, 20, 24, 28, 32 等
|
||||
字体大小, 使用正整数, 单位px
|
||||
示例:12, 14, 16, 18, 20, 24, 28, 32 等
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:double">
|
||||
<xs:minInclusive value="1"/>
|
||||
<xs:maxInclusive value="4000"/>
|
||||
<xs:restriction base="xs:positiveInteger">
|
||||
<xs:minInclusive value="6"/>
|
||||
<xs:maxInclusive value="400"/>
|
||||
<xs:pattern value="[0-9]+"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
@@ -210,52 +211,6 @@
|
||||
</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>
|
||||
@@ -277,35 +232,6 @@
|
||||
</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">
|
||||
@@ -855,64 +781,21 @@
|
||||
<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>
|
||||
裁剪配置: 将原图裁剪到目标尺寸 (img 元素的 width × height)
|
||||
裁剪配置: 原图填充到预裁剪区域,再根据offset裁出最终尺寸
|
||||
|
||||
可选属性:
|
||||
type: 裁剪形状, 默认 rect
|
||||
anchor: 裁剪方位 (top/bottom/left/right), 参见 CropAnchorType
|
||||
leftOffset / rightOffset / topOffset / bottomOffset: 四向偏移量 (px), 正值向内裁剪、负值向外扩展留白、0对齐边缘
|
||||
presetHandlers: 控制点配置, 对应 ECMA 预设形状的控制点。单个或多个数字, 多个用逗号分隔。
|
||||
示例: type="rect" 且 presetHandlers="60" 时为圆角矩形, 圆角半径 60px
|
||||
type: 裁剪形状,默认rect
|
||||
leftOffset, rightOffset, topOffset, bottomOffset: 边缘偏移量(px)。正值向内裁剪,负值向外扩展留白,0值对齐边缘
|
||||
presetHandlers: 控制点配置,对应ECMA预设形状的控制点。单个或多个数字,多个用逗号分隔。示例: type="rect"且presetHandlers="60"时为圆角矩形,圆角半径60px
|
||||
|
||||
【推荐用法】使用 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"/> 圆角矩形裁剪, 默认居中
|
||||
说明: 指定offset时,若预裁剪尺寸与原图比例不一致会产生拉伸变形。无法确定原图比例时,不要指定offset
|
||||
</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"/>
|
||||
@@ -1159,10 +1042,6 @@
|
||||
- underline: content 级别是否下划线
|
||||
- list: content 级别列表类型 bullet/number
|
||||
- listStyle: content 级别列表样式
|
||||
- bulletColor: 列表符号颜色(纯色), 可选
|
||||
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)如 "100%", 或绝对像素值(取值范围 6-400)如 "14"
|
||||
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 作为后代段落的初始计数器, 子元素 <p>/<ol> 可通过自身 autoStartAt 重置
|
||||
- bulletChar: 自定义列表符号字符, 可选, 如 "★", "→" 等, 设置后覆盖 listStyle 的符号
|
||||
- anchorCenter: 控制文本对齐方式, 优先级高于 textAlign
|
||||
- autoFit: 控制文本编辑溢出时处理策略
|
||||
- baseline: 上标/下标, 相较于文本基线的偏移量
|
||||
@@ -1170,13 +1049,6 @@
|
||||
|
||||
注意:如果content子元素不指定属性, 默认继承content的属性值, 如果局部子元素指定了属性, 则使用局部属性值
|
||||
|
||||
autoStartAt 运行计数器示例(显式指定重置, 未指定沿用前序计数器):
|
||||
<content autoStartAt="5">
|
||||
<p list="number">A</p> <!-- A=5, 继承 content 初始值 -->
|
||||
<p list="number" autoStartAt="10">B</p> <!-- B=10, 本段显式重置 -->
|
||||
<p list="number">C</p> <!-- C=11, 沿用前序计数器递增 -->
|
||||
</content>
|
||||
|
||||
子元素:
|
||||
- p: 段落元素
|
||||
- ul: 无序列表元素
|
||||
@@ -1213,10 +1085,6 @@
|
||||
<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" />
|
||||
@@ -1228,9 +1096,9 @@
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
段落容器, 支持富文本内容
|
||||
可包含纯文本和内联格式元素(br/strong/em/u/span/del/a/shadow/outline/formula/field)
|
||||
可包含纯文本和内联格式元素(br/strong/em/u/span/del/a/shadow/outline)
|
||||
内联元素嵌套:所有内联元素均可包含纯文本或其他内联元素,以实现复杂的格式组合
|
||||
元素自嵌套:除a/formula元素外,其余内联元素支持自身嵌套,当shadow和outline自嵌套时,渲染效果遵循就近原则,以内层定义的样式为准
|
||||
元素自嵌套:除a元素外,其余内联元素支持自身嵌套,当shadow和outline自嵌套时,渲染效果遵循就近原则,以内层定义的样式为准
|
||||
|
||||
空格处理规则:
|
||||
- 文本内的连续空格会被合并为单个空格
|
||||
@@ -1251,8 +1119,6 @@
|
||||
- a: 超链接
|
||||
- shadow: 文本阴影
|
||||
- outline: 文本轮廓
|
||||
- formula: 科学公式(支持数学、物理等)
|
||||
- field: 动态文本字段,元素内容作为不支持动态字段时的降级文本
|
||||
属性说明:
|
||||
- textAlign: 文本对齐方式
|
||||
- lineSpacing: 行间距
|
||||
@@ -1261,10 +1127,6 @@
|
||||
- level: 段落级别, 取值范围 [1,10]
|
||||
- list: 列表类型(bullet/number)
|
||||
- listStyle: 列表样式
|
||||
- bulletColor: 列表符号颜色(纯色), 可选
|
||||
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400)
|
||||
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 显式指定时从当前段落起重置计数器, 未指定时沿用同一 content 内的前序计数器
|
||||
- bulletChar: 自定义列表符号字符, 可选
|
||||
- marginLeft: 段落左侧缩进宽度
|
||||
- indent: 首行缩进宽度
|
||||
</xs:documentation>
|
||||
@@ -1272,7 +1134,6 @@
|
||||
<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"/>
|
||||
@@ -1281,7 +1142,6 @@
|
||||
<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"/>
|
||||
@@ -1291,10 +1151,6 @@
|
||||
<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>
|
||||
@@ -1304,14 +1160,7 @@
|
||||
<!-- 无序列表 -->
|
||||
<xs:element name="ul">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
无序列表
|
||||
属性说明:
|
||||
- listStyle: 列表样式
|
||||
- bulletColor: 列表符号颜色(纯色), 可选
|
||||
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400)
|
||||
- bulletChar: 自定义列表符号字符, 可选, 设置后覆盖 listStyle 的符号
|
||||
</xs:documentation>
|
||||
<xs:documentation>无序列表</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
@@ -1324,23 +1173,13 @@
|
||||
</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>
|
||||
有序列表, 可指定序号
|
||||
属性说明:
|
||||
- listStyle: 列表样式
|
||||
- bulletColor: 列表符号颜色(纯色), 可选
|
||||
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400)
|
||||
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 作用于本列表组的计数器初始值, 子元素 <li@index> 可覆盖单项编号
|
||||
</xs:documentation>
|
||||
<xs:documentation>有序列表, 可指定序号</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
@@ -1354,9 +1193,6 @@
|
||||
</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>
|
||||
|
||||
@@ -1547,7 +1383,7 @@
|
||||
alpha: 不透明度[0, 1]
|
||||
|
||||
可选子元素:
|
||||
crop: 裁剪。无标签 / 空标签 / 仅设 anchor 时按等比缩放后裁剪到 width×height; anchor 指定保留方位 (top/bottom/left/right), 不设 anchor 即居中裁剪; offset 用于精细控制
|
||||
crop: 裁剪。无标签或所有offset未设置时从左上角自适应裁到width×height
|
||||
reflection: 倒影。无标签代表无倒影,空标签代表使用默认样式
|
||||
shadow: 阴影。无标签代表无阴影,空标签代表使用默认样式
|
||||
border: 边框。无标签代表无边框,空标签代表使用默认样式(颜色: rgba(43, 47, 54, 1), 宽度: 2)
|
||||
@@ -1664,7 +1500,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>
|
||||
@@ -1722,70 +1558,6 @@
|
||||
<xs:complexType/>
|
||||
</xs:element>
|
||||
|
||||
<xs:element name="field">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
动态文本字段。
|
||||
type 属性描述动态语义,元素内容是静态降级文本,可包含行内样式元素。
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType mixed="true">
|
||||
<xs:choice minOccurs="0" maxOccurs="unbounded">
|
||||
<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:choice>
|
||||
<xs:attribute name="type" type="sml:FieldType" use="required"/>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
|
||||
|
||||
|
||||
<xs:element name="formula">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
通用公式元素。
|
||||
用于展示各类科学公式。
|
||||
|
||||
结构说明:
|
||||
- 必须从支持的公式格式中选择且仅选择一种作为子元素。
|
||||
- 当前版本支持格式:<latex>
|
||||
|
||||
示例:
|
||||
- 基础公式:
|
||||
<formula>
|
||||
<latex><![CDATA[ E = mc^2 ]]></latex>
|
||||
</formula>
|
||||
</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 转义(如 &lt;, &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>
|
||||
@@ -1793,7 +1565,6 @@
|
||||
<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"/>
|
||||
@@ -1802,7 +1573,6 @@
|
||||
<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>
|
||||
@@ -1820,7 +1590,6 @@
|
||||
<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"/>
|
||||
@@ -1829,7 +1598,6 @@
|
||||
<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>
|
||||
@@ -1849,7 +1617,6 @@
|
||||
<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"/>
|
||||
@@ -1858,7 +1625,6 @@
|
||||
<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>
|
||||
@@ -1872,7 +1638,6 @@
|
||||
<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"/>
|
||||
@@ -1881,7 +1646,6 @@
|
||||
<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>
|
||||
@@ -1893,7 +1657,6 @@
|
||||
<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"/>
|
||||
@@ -1902,7 +1665,6 @@
|
||||
<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>
|
||||
@@ -1914,7 +1676,6 @@
|
||||
<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"/>
|
||||
@@ -1923,7 +1684,6 @@
|
||||
<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>
|
||||
@@ -1938,7 +1698,6 @@
|
||||
<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"/>
|
||||
@@ -1947,7 +1706,6 @@
|
||||
<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"/>
|
||||
@@ -1971,7 +1729,6 @@
|
||||
<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"/>
|
||||
@@ -1979,7 +1736,6 @@
|
||||
<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>
|
||||
@@ -2067,116 +1823,36 @@
|
||||
<!-- 有序列表样式枚举 -->
|
||||
<xs:simpleType name="OrderedListStyle">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
有序列表样式
|
||||
|
||||
分为两类:
|
||||
1. 复合样式(按层级循环不同格式):如 number-lower-alpha-lower-roman 表示第1级用数字、第2级用小写字母、第3级用小写罗马,超过层级数后循环
|
||||
2. 单一样式(所有层级使用同一格式,不循环):以 PPTX 标准 scheme 命名,如 alpha-lc-paren-both 表示所有层级都用 (a)(b)(c) 格式
|
||||
</xs:documentation>
|
||||
<xs:documentation>有序列表样式</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>1.2.3.- 全角数字带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="arabic-db-plain"><xs:annotation><xs:documentation>1 2 3 - 全角纯数字</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>
|
||||
无序列表样式
|
||||
|
||||
分为两类:
|
||||
1. 复合样式(按层级循环不同图标):如 circle-hollow-square 表示第1级实心圆、第2级空心圆、第3级实心方形,超过层级数后循环
|
||||
2. 单一样式(所有层级使用同一图标,不循环):以 pptx- 前缀命名,如 pptx-circle 表示所有层级都用 ● 实心圆
|
||||
</xs:documentation>
|
||||
<xs:documentation>无序列表样式</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>
|
||||
|
||||
@@ -2420,19 +2096,6 @@
|
||||
</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>
|
||||
@@ -2554,7 +2217,6 @@
|
||||
|
||||
属性:
|
||||
- textAlign: 文本对齐方式(left|center|right), 默认left
|
||||
- fontFamily: 字体族名称,仅图表根级主标题/副标题支持;坐标轴标题不支持
|
||||
- fontSize: 字号大小
|
||||
- bold: 是否加粗
|
||||
- italic: 是否斜体, 默认false
|
||||
@@ -2568,7 +2230,6 @@
|
||||
<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>
|
||||
@@ -2637,10 +2298,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"/>
|
||||
<xs:attribute name="color" type="sml:SolidColor" use="optional" default="rgb(255, 255, 255)"/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="ChartBorderType">
|
||||
@@ -2650,7 +2311,7 @@
|
||||
|
||||
属性:
|
||||
- color: 边框颜色,默认 rgb(222, 224, 227)
|
||||
- width: 边框宽度(像素), 默认 1;无边框可设置为0,或不设置chartBorder
|
||||
- width: 边框宽度(像素), 默认 1
|
||||
- style: 边框样式(solid|dashed|dotted), 默认 solid
|
||||
- radius: 圆角半径(像素), 默认 6
|
||||
</xs:documentation>
|
||||
@@ -2661,61 +2322,6 @@
|
||||
<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>
|
||||
@@ -2825,16 +2431,12 @@
|
||||
- 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>
|
||||
@@ -2846,14 +2448,8 @@
|
||||
- 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"/>
|
||||
@@ -2866,7 +2462,7 @@
|
||||
</xs:complexType>
|
||||
|
||||
<!-- 线条配置 -->
|
||||
<xs:complexType name="ChartGlobalLineType">
|
||||
<xs:complexType name="ChartLineType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
图表全局线条配置(第一层:所有系列的默认样式)
|
||||
@@ -2883,27 +2479,8 @@
|
||||
<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="ChartGlobalAreaType">
|
||||
<xs:complexType name="ChartAreaType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
图表全局面积配置(第一层:所有系列的默认填充样式)
|
||||
@@ -2916,25 +2493,6 @@
|
||||
<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>
|
||||
@@ -2975,16 +2533,12 @@
|
||||
- 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>
|
||||
@@ -2997,14 +2551,8 @@
|
||||
- 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"/>
|
||||
@@ -3029,14 +2577,8 @@
|
||||
- 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"/>
|
||||
@@ -3056,12 +2598,10 @@
|
||||
- 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"/>
|
||||
@@ -3108,8 +2648,8 @@
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="chartPoints" type="sml:ChartSeriesPointsType" minOccurs="0"/>
|
||||
<xs:element name="chartLine" type="sml:ChartSeriesLineType" minOccurs="0"/>
|
||||
<xs:element name="chartArea" type="sml:ChartSeriesAreaType" minOccurs="0"/>
|
||||
<xs:element name="chartLine" type="sml:ChartLineType" minOccurs="0"/>
|
||||
<xs:element name="chartArea" type="sml:ChartAreaType" 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"/>
|
||||
@@ -3310,8 +2850,8 @@
|
||||
</xs:annotation>
|
||||
<xs:all>
|
||||
<xs:element name="chartPoints" type="sml:ChartGlobalPointsType" minOccurs="0"/>
|
||||
<xs:element name="chartLines" type="sml:ChartGlobalLineType" minOccurs="0"/>
|
||||
<xs:element name="chartAreas" type="sml:ChartGlobalAreaType" minOccurs="0"/>
|
||||
<xs:element name="chartLines" type="sml:ChartLineType" minOccurs="0"/>
|
||||
<xs:element name="chartAreas" type="sml:ChartAreaType" 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"/>
|
||||
|
||||
@@ -129,15 +129,6 @@ 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`。
|
||||
|
||||
示例:
|
||||
|
||||
@@ -321,36 +312,6 @@ 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)。
|
||||
|
||||
@@ -49,24 +49,6 @@ 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
|
||||
# A <line> crossing text glyphs is a legibility defect (see line_crosses_text_glyphs). We erode the
|
||||
# glyph box by this margin before testing intersection so a line that only skims a glyph edge or the
|
||||
# padding-only text frame -- but does not actually cut through the letterforms -- is not flagged.
|
||||
LINE_TEXT_GRAZE_MIN_PX = 2.0
|
||||
LINE_TEXT_GRAZE_FONT_RATIO = 0.12
|
||||
# A line whose effective stroke alpha is below this is not visibly rendered, so it cannot occlude text.
|
||||
LINE_MIN_VISIBLE_ALPHA = 0.08
|
||||
# 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
|
||||
@@ -124,52 +106,6 @@ 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)
|
||||
|
||||
@@ -282,7 +218,6 @@ 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
|
||||
@@ -759,20 +694,6 @@ 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"),
|
||||
@@ -784,17 +705,11 @@ 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),
|
||||
}
|
||||
@@ -830,11 +745,7 @@ 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) and not is_ghost_text(element)
|
||||
]
|
||||
text_elements = [element for element in elements if is_text_element(element) and has_text_content(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:
|
||||
@@ -871,135 +782,22 @@ def normalize_text_for_overlap(text: str) -> str:
|
||||
return re.sub(r"\s+", "", text)
|
||||
|
||||
|
||||
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
|
||||
def estimate_character_width(character: str, font_size: int | float) -> int | float:
|
||||
if character.isspace():
|
||||
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
|
||||
return font_size * 0.33
|
||||
if unicodedata.east_asian_width(character) in {"F", "W"}:
|
||||
return font_size
|
||||
return font_size * 0.55
|
||||
|
||||
|
||||
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_width(text: str, font_size: int | float) -> int | float:
|
||||
return sum(estimate_character_width(character, font_size) for character in text)
|
||||
|
||||
|
||||
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, letter_spacing, bold, font_family) for paragraph in paragraphs]
|
||||
or [1]
|
||||
)
|
||||
return max([estimate_text_width(paragraph, font_size) for paragraph in paragraphs] or [1])
|
||||
|
||||
|
||||
def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||||
@@ -1012,14 +810,8 @@ 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, paragraph: dict[str, Any] | None = None
|
||||
) -> int:
|
||||
def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> 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
|
||||
@@ -1028,12 +820,8 @@ def estimate_text_line_count_for_text(
|
||||
if element.get("wrap") in {"false", "0"}:
|
||||
line_count += 1
|
||||
continue
|
||||
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))
|
||||
logical_width = max(estimate_text_width(hard_line, font_size), 1)
|
||||
line_count += max(1, math.ceil(logical_width / max(element["width"], 1)))
|
||||
return line_count
|
||||
|
||||
|
||||
@@ -1043,8 +831,7 @@ 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
|
||||
if line_spacing is None:
|
||||
return font_size * DEFAULT_TEXT_LINE_SPACING_MULTIPLE
|
||||
line_spacing = line_spacing or "multiple:1.5"
|
||||
match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
|
||||
if match is None:
|
||||
return None
|
||||
@@ -1052,27 +839,12 @@ 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 has_explicit_height_auto_fit(element):
|
||||
if element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}:
|
||||
continue
|
||||
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
@@ -1088,11 +860,10 @@ 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)
|
||||
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"])
|
||||
if paragraph_line_count == 0:
|
||||
continue
|
||||
resolved_line_spacing = paragraph["lineSpacing"] or element["lineSpacing"]
|
||||
line_height = estimate_text_line_height(element, resolved_line_spacing)
|
||||
line_height = estimate_text_line_height(element, paragraph["lineSpacing"] or element["lineSpacing"])
|
||||
before_spacing = estimate_text_line_height(
|
||||
element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
|
||||
)
|
||||
@@ -1102,7 +873,6 @@ 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)
|
||||
@@ -1113,24 +883,12 @@ 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 <= text_height_overflow_tolerance():
|
||||
if overflow <= 0:
|
||||
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": level,
|
||||
"level": "warning",
|
||||
"code": "text_may_overflow_shape",
|
||||
"elements": [element["id"]],
|
||||
"line_count": line_count,
|
||||
@@ -1138,7 +896,11 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
||||
"estimated_height": estimated_height,
|
||||
"available_height": available_height,
|
||||
"overflow": overflow,
|
||||
"message": message,
|
||||
"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"'
|
||||
),
|
||||
"hint": (
|
||||
"Increase shape.height, reduce the text, or set content wrap=\"true\" "
|
||||
"autoFit=\"normal-auto-fit\". "
|
||||
@@ -1149,38 +911,6 @@ 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
|
||||
@@ -1292,8 +1022,6 @@ 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
|
||||
|
||||
@@ -1310,16 +1038,13 @@ 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 - available_width
|
||||
min_overflow = max(font_size * 1.5, available_width * 0.08)
|
||||
overflow_width = visual_width - source["width"]
|
||||
min_overflow = max(font_size * 1.5, source["width"] * 0.08)
|
||||
if overflow_width < min_overflow:
|
||||
return False
|
||||
|
||||
intrusion_width = source["x"] + padding_left + visual_width - target["x"]
|
||||
intrusion_width = source["x"] + visual_width - target["x"]
|
||||
min_intrusion = max(font_size * 1.5, target["width"] * 0.08)
|
||||
if intrusion_width < min_intrusion:
|
||||
return False
|
||||
@@ -1331,9 +1056,8 @@ 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"] + padding_left, "y": source["y"], "width": visual_width, "height": source["height"]}
|
||||
source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]}
|
||||
width = intersection_width(source_visual_bbox, target)
|
||||
height = intersection_height(source_visual_bbox, target)
|
||||
return {
|
||||
@@ -1348,8 +1072,6 @@ 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):
|
||||
@@ -1396,8 +1118,6 @@ 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):
|
||||
@@ -1474,8 +1194,6 @@ 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
|
||||
@@ -1501,12 +1219,7 @@ 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 (
|
||||
element
|
||||
for element in elements
|
||||
if element["kind"] in {"table", "chart"}
|
||||
or (element["kind"] == "shape" and element["type"] in {"rect", "text"})
|
||||
):
|
||||
for element in elements:
|
||||
bbox = element_canvas_bbox(element)
|
||||
overflow = {
|
||||
"left": max(-bbox["x"], 0),
|
||||
@@ -1616,93 +1329,6 @@ def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[
|
||||
return issues
|
||||
|
||||
|
||||
def segment_intersects_rect(
|
||||
x1: float, y1: float, x2: float, y2: float, rect: dict[str, int | float]
|
||||
) -> bool:
|
||||
"""True when segment (x1,y1)-(x2,y2) enters the axis-aligned rect (Liang-Barsky clip)."""
|
||||
left = rect["x"]
|
||||
top = rect["y"]
|
||||
right = rect["x"] + rect["width"]
|
||||
bottom = rect["y"] + rect["height"]
|
||||
if right <= left or bottom <= top:
|
||||
return False
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
if dx == 0 and dy == 0:
|
||||
return left <= x1 <= right and top <= y1 <= bottom
|
||||
t_enter, t_exit = 0.0, 1.0
|
||||
for delta, distance in ((-dx, x1 - left), (dx, right - x1), (-dy, y1 - top), (dy, bottom - y1)):
|
||||
if delta == 0:
|
||||
if distance < 0:
|
||||
return False
|
||||
continue
|
||||
t = distance / delta
|
||||
if delta < 0:
|
||||
t_enter = max(t_enter, t)
|
||||
else:
|
||||
t_exit = min(t_exit, t)
|
||||
if t_enter > t_exit:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def line_text_graze_margin(text_element: dict[str, Any]) -> float:
|
||||
font_size = text_element["fontSize"] if isinstance(text_element.get("fontSize"), (int, float)) else 16
|
||||
return max(font_size * LINE_TEXT_GRAZE_FONT_RATIO, LINE_TEXT_GRAZE_MIN_PX)
|
||||
|
||||
|
||||
def erode_rect(rect: dict[str, int | float], margin: float) -> dict[str, int | float] | None:
|
||||
width = rect["width"] - 2 * margin
|
||||
height = rect["height"] - 2 * margin
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
return {"x": rect["x"] + margin, "y": rect["y"] + margin, "width": width, "height": height}
|
||||
|
||||
|
||||
def line_crosses_text(line: dict[str, Any], text_element: dict[str, Any]) -> bool:
|
||||
if not is_visually_rendered(line) or line.get("alpha", 1) < LINE_MIN_VISIBLE_ALPHA:
|
||||
return False
|
||||
if not is_text_element(text_element) or not has_text_content(text_element):
|
||||
return False
|
||||
if is_ghost_text(text_element) or is_decorative_text(text_element):
|
||||
return False
|
||||
glyph_bbox = estimate_text_visual_bbox(text_element)
|
||||
if glyph_bbox is None:
|
||||
return False
|
||||
# Erode the glyph box so a line skimming the letter edge or only clipping the padding-only text
|
||||
# frame is exempt; only a line that actually cuts through the letterforms is a crossing.
|
||||
target = erode_rect(glyph_bbox, line_text_graze_margin(text_element))
|
||||
if target is None:
|
||||
return False
|
||||
return segment_intersects_rect(
|
||||
line["startX"], line["startY"], line["endX"], line["endY"], target
|
||||
)
|
||||
|
||||
|
||||
def detect_line_text_crossings(
|
||||
slide_xml: str, elements: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
lines = extract_line_elements(slide_xml)
|
||||
if not lines:
|
||||
return []
|
||||
text_elements = [element for element in elements if is_text_element(element)]
|
||||
issues: list[dict[str, Any]] = []
|
||||
for line in lines:
|
||||
for text_element in text_elements:
|
||||
if not line_crosses_text(line, text_element):
|
||||
continue
|
||||
issues.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "bbox_overlap",
|
||||
"elements": [line["id"], text_element["id"]],
|
||||
"message": f'line {line["id"]} crosses text {text_element["id"]}',
|
||||
"hint": "Move the line off the text glyphs so it no longer cuts through the letterforms.",
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def lint_slide(
|
||||
slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
|
||||
) -> dict[str, Any]:
|
||||
@@ -1713,7 +1339,6 @@ def lint_slide(
|
||||
*detect_table_layout_size_mismatches(elements),
|
||||
*detect_text_may_overflow_shapes(elements),
|
||||
*detect_image_text_occlusions(elements),
|
||||
*detect_line_text_crossings(slide_xml, elements),
|
||||
]
|
||||
|
||||
for index, left in enumerate(elements):
|
||||
@@ -2319,7 +1944,7 @@ def related_object(element: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
elements: list[dict[str, Any]] = []
|
||||
for match in re.finditer(r"<line\b([^>]*?)(/?)>", slide_xml):
|
||||
for match in re.finditer(r"<line\b([^>]*)>", slide_xml):
|
||||
attrs = match.group(1)
|
||||
start_x = extract_numeric_attribute(attrs, "startX")
|
||||
start_y = extract_numeric_attribute(attrs, "startY")
|
||||
@@ -2328,15 +1953,6 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
if any(value is None for value in (start_x, start_y, end_x, end_y)):
|
||||
continue
|
||||
line_alpha = extract_numeric_attribute(attrs, "alpha")
|
||||
base_alpha = line_alpha if line_alpha is not None else 1
|
||||
border_alpha = 1
|
||||
if match.group(2) != "/":
|
||||
close_index = slide_xml.find("</line>", match.end())
|
||||
body = slide_xml[match.end() : close_index] if close_index != -1 else ""
|
||||
border_attrs = extract_tag_attributes(body, "border")
|
||||
color_alpha = extract_color_alpha(extract_attribute(border_attrs, "color"))
|
||||
if isinstance(color_alpha, (int, float)):
|
||||
border_alpha = color_alpha
|
||||
elements.append(
|
||||
{
|
||||
"id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
|
||||
@@ -2346,12 +1962,8 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
"y": min(start_y, end_y),
|
||||
"width": abs(end_x - start_x),
|
||||
"height": abs(end_y - start_y),
|
||||
"startX": start_x,
|
||||
"startY": start_y,
|
||||
"endX": end_x,
|
||||
"endY": end_y,
|
||||
"rotation": 0,
|
||||
"alpha": base_alpha * border_alpha,
|
||||
"alpha": line_alpha if line_alpha is not None else 1,
|
||||
"order": len(elements),
|
||||
}
|
||||
)
|
||||
@@ -2364,6 +1976,8 @@ 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
|
||||
@@ -2425,10 +2039,8 @@ 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)
|
||||
@@ -2441,7 +2053,6 @@ 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,
|
||||
@@ -2449,7 +2060,6 @@ def build_result(
|
||||
"document": {
|
||||
"errors": document_errors,
|
||||
"warnings": document_warnings,
|
||||
"infos": document_infos,
|
||||
},
|
||||
"slides": slides,
|
||||
}
|
||||
@@ -2540,7 +2150,6 @@ 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,
|
||||
@@ -2548,7 +2157,6 @@ 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,
|
||||
}
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user