mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
32 Commits
feat/sessi
...
feat/slide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79b2b499d | ||
|
|
e0bc29a648 | ||
|
|
68a77eee5c | ||
|
|
29a97dbde8 | ||
|
|
29a6a7b600 | ||
|
|
c167163d70 | ||
|
|
7988515e1c | ||
|
|
c7adff7a3b | ||
|
|
59237f3104 | ||
|
|
358cd06838 | ||
|
|
b0b1ca4b5d | ||
|
|
781d188a60 | ||
|
|
2e0fb9a880 | ||
|
|
927b37cd63 | ||
|
|
d2e22c5fca | ||
|
|
fdae560014 | ||
|
|
1b173e1953 | ||
|
|
57db1b3a8d | ||
|
|
4c1c5f5287 | ||
|
|
3d2c10cd0b | ||
|
|
03de81c5f3 | ||
|
|
7abcaa7f68 | ||
|
|
8fb2476985 | ||
|
|
56c9a2afd8 | ||
|
|
2029189809 | ||
|
|
ee427979a8 | ||
|
|
545abcbbde | ||
|
|
4a73e83f1e | ||
|
|
7496420fa8 | ||
|
|
43fabdf524 | ||
|
|
8c46c74105 | ||
|
|
70777c86c3 |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,7 @@
|
||||
/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,19 +25,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
@@ -253,19 +250,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
|
||||
1
.lark-slides-on9-readback.xml
Normal file
1
.lark-slides-on9-readback.xml
Normal file
File diff suppressed because one or more lines are too long
58
CHANGELOG.md
58
CHANGELOG.md
@@ -2,6 +2,61 @@
|
||||
|
||||
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
|
||||
@@ -1667,6 +1722,9 @@ 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 = newAuthTestCredentialProvider("test-app", tokenResolver)
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
|
||||
appInfoStub := &httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
@@ -442,7 +442,7 @@ func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T)
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
@@ -485,18 +485,6 @@ type authScopesTokenResolver struct {
|
||||
requests []credential.TokenSpec
|
||||
}
|
||||
|
||||
type authTestAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r authTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID, Brand: core.BrandFeishu}, nil
|
||||
}
|
||||
|
||||
func newAuthTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
|
||||
return credential.NewCredentialProvider(nil, authTestAccountResolver{appID: appID}, tokenResolver, nil)
|
||||
}
|
||||
|
||||
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
r.requests = append(r.requests, req)
|
||||
switch req.Type {
|
||||
|
||||
@@ -27,9 +27,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "View current auth status",
|
||||
Long: `Show OAuth user login, token validity, and granted scopes.
|
||||
For token-validity checks, run lark-cli auth status --json --verify.
|
||||
This is not profile/app selection diagnostics; use lark-cli whoami for the effective app/profile identity used by an invocation.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
|
||||
@@ -4,35 +4,15 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAuthStatusHelpDistinguishesFromWhoami(t *testing.T) {
|
||||
cmd := NewCmdAuthStatus(nil, nil)
|
||||
for _, want := range []string{
|
||||
"OAuth user login",
|
||||
"auth status --json --verify",
|
||||
"not profile/app selection diagnostics",
|
||||
"lark-cli whoami",
|
||||
} {
|
||||
if !strings.Contains(cmd.Long, want) {
|
||||
t.Errorf("auth status --help Long missing %q; got:\n%s", want, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
@@ -99,51 +79,6 @@ func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type fixedStatusAccountResolver struct {
|
||||
account *credential.Account
|
||||
}
|
||||
|
||||
func (r *fixedStatusAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return r.account, nil
|
||||
}
|
||||
|
||||
func TestAuthStatus_AllowsMatchingAppIDOnlySelectedProfile(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliAppID, "cli_a")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a",
|
||||
AppId: "cli_a",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
config := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, config)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&envprovider.Provider{}},
|
||||
&fixedStatusAccountResolver{account: credential.AccountFromCliConfig(config)},
|
||||
nil,
|
||||
nil,
|
||||
).WithProfileFromFlag("tenant_a")
|
||||
|
||||
cmd := NewCmdAuth(f)
|
||||
cmd.SetArgs([]string{"status", "--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status should use the selected built-in profile: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "credentials are provided externally") {
|
||||
t.Fatalf("matching APP_ID-only env was misclassified as external:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
type statusOutput struct {
|
||||
Identity string `json:"identity"`
|
||||
Verified *bool `json:"verified"`
|
||||
|
||||
@@ -6,10 +6,8 @@ package cmd
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -28,13 +26,5 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
|
||||
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
|
||||
return cmdutil.InvocationContext{}, err
|
||||
}
|
||||
|
||||
profileFromFlag := fs.Changed("profile")
|
||||
if !profileFromFlag {
|
||||
globals.Profile = os.Getenv(envvars.CliProfile)
|
||||
}
|
||||
return cmdutil.InvocationContext{
|
||||
Profile: globals.Profile,
|
||||
ProfileFromFlag: profileFromFlag,
|
||||
}, nil
|
||||
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||
@@ -74,58 +70,3 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
|
||||
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapProfileEnvFallback(t *testing.T) {
|
||||
t.Run("flag wins over env", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "tenant_env")
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "tenant_flag", "whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "tenant_flag" {
|
||||
t.Errorf("got %q, want tenant_flag", inv.Profile)
|
||||
}
|
||||
if !inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = false, want true")
|
||||
}
|
||||
})
|
||||
t.Run("explicit empty flag clears env selection", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "tenant_env")
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile=", "whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Errorf("got %q, want empty", inv.Profile)
|
||||
}
|
||||
if !inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = false, want true")
|
||||
}
|
||||
})
|
||||
t.Run("env used when flag absent", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "tenant_env")
|
||||
inv, err := BootstrapInvocationContext([]string{"whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "tenant_env" {
|
||||
t.Errorf("got %q, want tenant_env", inv.Profile)
|
||||
}
|
||||
if inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = true, want false")
|
||||
}
|
||||
})
|
||||
t.Run("empty when neither set", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "")
|
||||
inv, err := BootstrapInvocationContext([]string{"whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Errorf("got %q, want empty", inv.Profile)
|
||||
}
|
||||
if inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = true, want false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,16 +84,6 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowHelpClarifiesSavedConfig(t *testing.T) {
|
||||
cmd := NewCmdConfigShow(nil, nil)
|
||||
if !strings.Contains(cmd.Short, "saved config") {
|
||||
t.Errorf("config show short = %q, want saved config", cmd.Short)
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
|
||||
t.Errorf("config show help missing whoami route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -116,77 +106,6 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// config show promises "saved config, not current usage" (help + skill
|
||||
// routing): the session profile (--profile / LARKSUITE_CLI_PROFILE) must not
|
||||
// change what it shows.
|
||||
func TestConfigShowRun_IgnoresSessionProfile(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu},
|
||||
{Name: "tenant_b", AppId: "cli_b", AppSecret: core.PlainSecret("your-secret-b"), Brand: core.BrandFeishu},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Invocation.Profile = "tenant_b" // session selection must not leak in
|
||||
|
||||
if err := configShowRun(&ConfigShowOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("configShowRun: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"cli_a"`) || !strings.Contains(out, `"tenant_a"`) {
|
||||
t.Fatalf("output = %s, want the saved default tenant_a/cli_a", out)
|
||||
}
|
||||
if strings.Contains(out, `"cli_b"`) {
|
||||
t.Fatalf("output = %s, session profile tenant_b must not change saved-config view", out)
|
||||
}
|
||||
}
|
||||
|
||||
// engagedEnvStub simulates a fully engaged external credential provider.
|
||||
type engagedEnvStub struct{}
|
||||
|
||||
func (engagedEnvStub) Name() string { return "env" }
|
||||
func (engagedEnvStub) Priority() int { return 10 }
|
||||
func (engagedEnvStub) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return &extcred.Account{AppID: "cli_env", AppSecret: "your-password"}, nil // managed takeover
|
||||
}
|
||||
func (engagedEnvStub) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// config show inspects the SAVED config only, so the parent command's
|
||||
// external-credential gate must not apply: even with a fully engaged direct
|
||||
// env credential, `config show` still answers from the saved config.
|
||||
func TestConfigShow_BypassesExternalCredentialGate(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Credential = credential.NewCredentialProvider([]extcred.Provider{engagedEnvStub{}}, nil, nil, nil)
|
||||
|
||||
cmd := NewCmdConfig(f)
|
||||
cmd.SetArgs([]string{"show"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("config show must bypass the external-credential gate: %v", err)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, `"cli_a"`) {
|
||||
t.Fatalf("output = %s, want the saved config shown", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
@@ -562,8 +481,7 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
||||
}{
|
||||
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
|
||||
{"remove", []string{"remove"}},
|
||||
// "show" is deliberately absent: it inspects the SAVED config only
|
||||
// and bypasses this gate (TestConfigShow_BypassesExternalCredentialGate).
|
||||
{"show", []string{"show"}},
|
||||
{"default-as", []string{"default-as", "user"}},
|
||||
{"strict-mode", []string{"strict-mode", "off"}},
|
||||
}
|
||||
|
||||
@@ -27,16 +27,7 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show saved config",
|
||||
Long: "Shows saved config. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
|
||||
// Override parent's RequireBuiltinCredentialProvider check: this
|
||||
// command reads the SAVED config only (its own help promises "saved
|
||||
// config, not current usage"), so the currently effective credential
|
||||
// source — external or otherwise — must not gate it.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
Short: "Show current configuration",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -62,10 +53,7 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
// Saved config only: the session profile (--profile / LARKSUITE_CLI_PROFILE)
|
||||
// must not change what this command shows — the help and skill routing
|
||||
// promise "saved config, not current usage" (use whoami for that).
|
||||
app := config.CurrentAppConfig("")
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
||||
}
|
||||
|
||||
@@ -110,20 +110,8 @@ func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSp
|
||||
return nil, errors.New("backend unavailable")
|
||||
}
|
||||
|
||||
type eventTestAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r eventTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID}, nil
|
||||
}
|
||||
|
||||
func newEventTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
|
||||
return credential.NewCredentialProvider(nil, eventTestAccountResolver{appID: appID}, tokenResolver, nil)
|
||||
}
|
||||
|
||||
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{Credential: newEventTestCredentialProvider("cli_x", r)}
|
||||
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||
|
||||
@@ -44,7 +44,7 @@ func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: newEventTestCredentialProvider("test-app", &staticTokenResolver{}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
|
||||
@@ -17,14 +17,11 @@ import (
|
||||
)
|
||||
|
||||
// profileListItem is the JSON output for a single profile entry.
|
||||
// `default` (formerly `active`, renamed in this feature as a declared
|
||||
// breaking change) marks the saved default profile — never the identity
|
||||
// effective for the current invocation; that is whoami's job.
|
||||
type profileListItem struct {
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
Default bool `json:"default"`
|
||||
Active bool `json:"active"`
|
||||
User string `json:"user,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
}
|
||||
@@ -33,8 +30,7 @@ type profileListItem struct {
|
||||
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List saved profiles",
|
||||
Long: "Lists saved profiles. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
|
||||
Short: "List all profiles",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileListRun(f)
|
||||
},
|
||||
@@ -57,7 +53,7 @@ func profileListRun(f *cmdutil.Factory) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intentionally uses "" to show the saved default profile, not the ephemeral --profile override.
|
||||
// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
|
||||
currentApp := multi.CurrentAppConfig("")
|
||||
currentName := ""
|
||||
if currentApp != nil {
|
||||
@@ -70,10 +66,10 @@ func profileListRun(f *cmdutil.Factory) error {
|
||||
name := app.ProfileName()
|
||||
|
||||
item := profileListItem{
|
||||
Name: name,
|
||||
AppID: app.AppId,
|
||||
Brand: app.Brand,
|
||||
Default: name == currentName,
|
||||
Name: name,
|
||||
AppID: app.AppId,
|
||||
Brand: app.Brand,
|
||||
Active: name == currentName,
|
||||
}
|
||||
|
||||
if len(app.Users) > 0 {
|
||||
|
||||
@@ -14,17 +14,6 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "Manage configuration profiles",
|
||||
Long: `Profiles are named app identities managed by lark-cli.
|
||||
|
||||
Identity diagnostics and profile selection:
|
||||
lark-cli whoami --json Show the app/profile lark-cli is using now.
|
||||
lark-cli auth status --json --verify Verify OAuth login and token state.
|
||||
--profile <name> Use a profile for this command only.
|
||||
LARKSUITE_CLI_PROFILE Use a profile for the current shell / agent session.
|
||||
config show / profile list Inspect saved config, not current usage.
|
||||
unset LARKSUITE_CLI_PROFILE Clear the session profile and fall back to direct app env or configured default.
|
||||
|
||||
A selected profile takes precedence over matching direct env credentials and tokens.`,
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
|
||||
@@ -306,24 +306,14 @@ func TestProfileListRun_OutputsProfiles(t *testing.T) {
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
|
||||
}
|
||||
raw := stdout.String()
|
||||
// `active` is renamed to `default` as a declared breaking change: keeping
|
||||
// a permanently mirrored alias would keep misleading agents into reading
|
||||
// it as the currently effective identity (whoami's job).
|
||||
if strings.Contains(raw, `"active"`) {
|
||||
t.Fatalf("profile list output contains renamed active field: %s", raw)
|
||||
}
|
||||
if !strings.Contains(raw, `"default"`) {
|
||||
t.Fatalf("profile list output missing default field: %s", raw)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Name != "default" || !got[0].Default {
|
||||
t.Fatalf("got[0] = %#v, want configured default profile", got[0])
|
||||
if got[0].Name != "default" || !got[0].Active {
|
||||
t.Fatalf("got[0] = %#v, want active default profile", got[0])
|
||||
}
|
||||
if got[1].Name != "target" || got[1].Default {
|
||||
t.Fatalf("got[1] = %#v, want non-default target profile", got[1])
|
||||
if got[1].Name != "target" || got[1].Active {
|
||||
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,39 +627,6 @@ func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestProfileHelpHasSelectionSection asserts `profile --help` documents the
|
||||
// per-invocation flag and session-scoped env var for selecting a profile, so
|
||||
// users and AI agents can find LARKSUITE_CLI_PROFILE without reading source.
|
||||
func TestProfileHelpHasSelectionSection(t *testing.T) {
|
||||
cmd := NewCmdProfile(nil)
|
||||
if !strings.Contains(cmd.Long, "Identity diagnostics and profile selection:") {
|
||||
t.Errorf("profile --help missing identity diagnostics and profile selection section")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "LARKSUITE_CLI_PROFILE") {
|
||||
t.Errorf("profile --help missing LARKSUITE_CLI_PROFILE")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
|
||||
t.Errorf("profile --help missing whoami identity route")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "config show / profile list") {
|
||||
t.Errorf("profile --help missing saved-config boundary")
|
||||
}
|
||||
const precedence = "A selected profile takes precedence over matching direct env credentials and tokens."
|
||||
if !strings.Contains(cmd.Long, precedence) {
|
||||
t.Errorf("profile --help missing precedence statement %q", precedence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListHelpClarifiesSavedProfiles(t *testing.T) {
|
||||
cmd := NewCmdProfileList(nil)
|
||||
if !strings.Contains(cmd.Short, "saved profiles") {
|
||||
t.Errorf("profile list short = %q, want saved profiles", cmd.Short)
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
|
||||
t.Errorf("profile list help missing whoami route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
|
||||
dir := setupProfileConfigDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
|
||||
|
||||
@@ -66,6 +66,7 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
|
||||
}
|
||||
}
|
||||
appendSlidesXMLQuickReference(&b, cmd, skillFS)
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
@@ -116,6 +117,90 @@ const (
|
||||
shortcutBaseAnnotation = "affordance-shortcut-base"
|
||||
)
|
||||
|
||||
const slidesXMLQuickReferencePath = "lark-slides/references/xml-schema-quick-ref.md"
|
||||
|
||||
// slidesShortcutReferencePaths maps each Slides shortcut to its primary
|
||||
// command guide. XML-consuming shortcuts also include the shared schema
|
||||
// reference because their command guide accepts XML but does not repeat the
|
||||
// complete element grammar.
|
||||
var slidesShortcutReferencePaths = map[string][]string{
|
||||
"+create": {
|
||||
"lark-slides/references/lark-slides-create.md",
|
||||
slidesXMLQuickReferencePath,
|
||||
},
|
||||
"+xml-get": {
|
||||
"lark-slides/references/lark-slides-xml-presentations-get.md",
|
||||
},
|
||||
"+screenshot": {
|
||||
"lark-slides/references/lark-slides-screenshot.md",
|
||||
},
|
||||
"+media-upload": {
|
||||
"lark-slides/references/lark-slides-media-upload.md",
|
||||
},
|
||||
"+replace-slide": {
|
||||
"lark-slides/references/lark-slides-replace-slide.md",
|
||||
"lark-slides/references/lark-slides-edit-workflows.md",
|
||||
slidesXMLQuickReferencePath,
|
||||
},
|
||||
"+replace-pages": {
|
||||
"lark-slides/references/lark-slides-replace-pages.md",
|
||||
"lark-slides/references/lark-slides-edit-workflows.md",
|
||||
slidesXMLQuickReferencePath,
|
||||
},
|
||||
"+history-list": {
|
||||
"lark-slides/references/lark-slides-history.md",
|
||||
},
|
||||
"+history-revert": {
|
||||
"lark-slides/references/lark-slides-history.md",
|
||||
},
|
||||
"+history-revert-status": {
|
||||
"lark-slides/references/lark-slides-history.md",
|
||||
},
|
||||
}
|
||||
|
||||
// appendSlidesXMLQuickReference adds the embedded XML schema summary to the
|
||||
// slides domain help. The reference file is already shipped in the skill
|
||||
// content tree, so help and the standalone skill reader share one source of
|
||||
// truth instead of maintaining a second, drifting copy in Go.
|
||||
func appendSlidesXMLQuickReference(b *strings.Builder, cmd *cobra.Command, skillFS fs.FS) {
|
||||
if cmd.Name() != "slides" || skillFS == nil {
|
||||
return
|
||||
}
|
||||
content, err := fs.ReadFile(skillFS, slidesXMLQuickReferencePath)
|
||||
if err != nil || len(content) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("\n\nEmbedded XML syntax quick reference:\n")
|
||||
b.Write(content)
|
||||
}
|
||||
|
||||
func readSlidesShortcutReferences(cmd *cobra.Command, skillFS fs.FS) ([]string, bool) {
|
||||
if cmdmeta.Domain(cmd) != "slides" || skillFS == nil {
|
||||
return nil, false
|
||||
}
|
||||
paths, ok := slidesShortcutReferencePaths[cmd.Name()]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var contents []string
|
||||
for _, path := range paths {
|
||||
content, err := fs.ReadFile(skillFS, path)
|
||||
if err != nil || len(content) == 0 {
|
||||
continue
|
||||
}
|
||||
contents = append(contents, fmt.Sprintf("Embedded command reference: %s\n%s", path, content))
|
||||
}
|
||||
return contents, len(contents) > 0
|
||||
}
|
||||
|
||||
func appendSlidesShortcutReferences(b *strings.Builder, contents []string) {
|
||||
for _, content := range contents {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(content)
|
||||
}
|
||||
}
|
||||
|
||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||
// few strings is the only build-time cost; the overlay stays untouched).
|
||||
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
|
||||
@@ -171,11 +256,11 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
}
|
||||
|
||||
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
|
||||
// overlay — the same top layout as method help (description, Risk, guidance
|
||||
// block, related skills) minus the schema pointer, which shortcuts have none
|
||||
// of. Returns false when the command is not a shortcut or carries no overlay
|
||||
// entry, so shortcuts without guidance keep the default help plus the bottom
|
||||
// risk/tips append.
|
||||
// overlay and any embedded command references — the same top layout as method
|
||||
// help (description, Risk, guidance block, related skills) minus the schema
|
||||
// pointer, which shortcuts have none of. Returns false when the command is not
|
||||
// a shortcut, or when it has neither an overlay nor an embedded reference, so
|
||||
// ordinary shortcuts keep the default help plus the bottom risk/tips append.
|
||||
//
|
||||
// The lead is the command's pristine base (captureHelpBase): a shortcut that
|
||||
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
|
||||
@@ -191,12 +276,17 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
|
||||
return false
|
||||
}
|
||||
raw, ok := affordanceRaw(cmd)
|
||||
if !ok {
|
||||
return false
|
||||
references, hasReferences := readSlidesShortcutReferences(cmd, skillFS)
|
||||
|
||||
var a meta.Affordance
|
||||
hasAffordance := false
|
||||
if raw, ok := affordanceRaw(cmd); ok {
|
||||
if parsed, parsedOK := (meta.Method{Affordance: raw}).ParsedAffordance(); parsedOK {
|
||||
a = parsed
|
||||
hasAffordance = true
|
||||
}
|
||||
}
|
||||
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
|
||||
if !ok {
|
||||
if !hasAffordance && !hasReferences {
|
||||
return false
|
||||
}
|
||||
if len(a.Tips) == 0 {
|
||||
@@ -211,6 +301,7 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
b.WriteString(block)
|
||||
}
|
||||
writeRelatedSkills(&b, a.Skills, skillFS)
|
||||
appendSlidesShortcutReferences(&b, references)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
|
||||
@@ -264,6 +264,94 @@ func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareShortcutHelp_SlidesReferenceWithoutAffordance(t *testing.T) {
|
||||
sc := &cobra.Command{Use: "+xml-get", Short: "Fetch presentation XML"}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetDomain(sc, "slides")
|
||||
cmdmeta.SetAffordanceRef(sc, "slides", "+xml-get")
|
||||
cmdutil.SetRisk(sc, "read")
|
||||
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-slides/references/lark-slides-xml-presentations-get.md": {
|
||||
Data: []byte("# slides +xml-get\n\nRead the presentation XML."),
|
||||
},
|
||||
}
|
||||
if !PrepareShortcutHelp(sc, skillFS) {
|
||||
t.Fatal("PrepareShortcutHelp returned false for a Slides shortcut with an embedded reference")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Fetch presentation XML",
|
||||
"Risk: read",
|
||||
"Embedded command reference: lark-slides/references/lark-slides-xml-presentations-get.md",
|
||||
"Read the presentation XML.",
|
||||
} {
|
||||
if !strings.Contains(sc.Long, want) {
|
||||
t.Errorf("Slides shortcut help missing %q:\n%s", want, sc.Long)
|
||||
}
|
||||
}
|
||||
PrepareShortcutHelp(sc, skillFS)
|
||||
if got := strings.Count(sc.Long, "Embedded command reference:"); got != 1 {
|
||||
t.Fatalf("embedded reference appended %d times after re-render, want 1:\n%s", got, sc.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesShortcutReferenceMapping(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"+create": "lark-slides/references/lark-slides-create.md",
|
||||
"+xml-get": "lark-slides/references/lark-slides-xml-presentations-get.md",
|
||||
"+screenshot": "lark-slides/references/lark-slides-screenshot.md",
|
||||
"+media-upload": "lark-slides/references/lark-slides-media-upload.md",
|
||||
"+replace-slide": "lark-slides/references/lark-slides-replace-slide.md",
|
||||
"+replace-pages": "lark-slides/references/lark-slides-replace-pages.md",
|
||||
"+history-list": "lark-slides/references/lark-slides-history.md",
|
||||
"+history-revert": "lark-slides/references/lark-slides-history.md",
|
||||
"+history-revert-status": "lark-slides/references/lark-slides-history.md",
|
||||
}
|
||||
|
||||
for command, path := range want {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
sc := &cobra.Command{Use: command, Short: command}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetDomain(sc, "slides")
|
||||
skillFS := fstest.MapFS{
|
||||
path: {Data: []byte("reference content")},
|
||||
}
|
||||
contents, ok := readSlidesShortcutReferences(sc, skillFS)
|
||||
if !ok || len(contents) == 0 {
|
||||
t.Fatalf("shortcut %q has no mapped reference", command)
|
||||
}
|
||||
if !strings.Contains(contents[0], "Embedded command reference: "+path) {
|
||||
t.Fatalf("shortcut %q mapped content does not include %q:\n%s", command, path, contents[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotHelpDoesNotIncludeXMLQuickReference(t *testing.T) {
|
||||
sc := &cobra.Command{Use: "+screenshot", Short: "Save screenshots"}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetDomain(sc, "slides")
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-slides/references/lark-slides-screenshot.md": {
|
||||
Data: []byte("# slides +screenshot\n\nSave screenshots."),
|
||||
},
|
||||
slidesXMLQuickReferencePath: {
|
||||
Data: []byte("# XML Schema Quick Reference"),
|
||||
},
|
||||
}
|
||||
|
||||
contents, ok := readSlidesShortcutReferences(sc, skillFS)
|
||||
if !ok {
|
||||
t.Fatal("screenshot shortcut should have a primary reference")
|
||||
}
|
||||
if len(contents) != 1 {
|
||||
t.Fatalf("screenshot reference count = %d, want 1: %#v", len(contents), contents)
|
||||
}
|
||||
if strings.Contains(contents[0], "XML Schema Quick Reference") {
|
||||
t.Fatalf("screenshot help must not include the XML quick reference:\n%s", contents[0])
|
||||
}
|
||||
}
|
||||
|
||||
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
||||
// shape PrepareDomainHelp expects.
|
||||
func domainCmd(short, long string) *cobra.Command {
|
||||
@@ -306,3 +394,51 @@ func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
||||
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDomainHelp_SlidesIncludesEmbeddedXMLReference(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
dom := &cobra.Command{Use: "slides", Short: "Slides"}
|
||||
cmdmeta.SetDomain(dom, "slides")
|
||||
dom.AddCommand(&cobra.Command{Use: "+create", Short: "Create", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(dom)
|
||||
|
||||
const quickReference = `# XML Schema Quick Reference
|
||||
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide>
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
|
||||
<content textType="title"><p>Title</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
|
||||
<table><colgroup><col/></colgroup><tr><td><content><p>A</p></content></td></tr></table>
|
||||
<chart><chartPlotArea/><chartData/></chart>`
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-slides/SKILL.md": {Data: []byte("# slides")},
|
||||
"lark-slides/references/xml-schema-quick-ref.md": {Data: []byte(quickReference)},
|
||||
}
|
||||
|
||||
if !PrepareDomainHelp(dom, skillFS) {
|
||||
t.Fatal("PrepareDomainHelp returned false for slides domain")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Embedded XML syntax quick reference:",
|
||||
`<presentation xmlns="http://www.larkoffice.com/sml/2.0"`,
|
||||
"<shape type=\"text\"",
|
||||
"<content",
|
||||
"topLeftX",
|
||||
"<table>",
|
||||
"<chart>",
|
||||
} {
|
||||
if !strings.Contains(dom.Long, want) {
|
||||
t.Errorf("slides help missing XML reference marker %q:\n%s", want, dom.Long)
|
||||
}
|
||||
}
|
||||
PrepareDomainHelp(dom, skillFS)
|
||||
if got := strings.Count(dom.Long, "Embedded XML syntax quick reference:"); got != 1 {
|
||||
t.Fatalf("slides XML reference appended %d times after re-render, want 1:\n%s", got, dom.Long)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -34,15 +33,6 @@ type whoamiResult struct {
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
|
||||
// CredentialSource, Explicit, and DirectCredentialEnv surface the cached
|
||||
// credential.IdentitySelection computed during resolution (not re-inferred
|
||||
// here). On the non-env extension-provider path CredentialSource is
|
||||
// "extension:<provider>" (e.g. "extension:sidecar"); an empty value only
|
||||
// means the selection was never resolved.
|
||||
CredentialSource string `json:"credentialSource"`
|
||||
Explicit bool `json:"explicit"`
|
||||
DirectCredentialEnv credential.DirectCredentialEnv `json:"directCredentialEnv"`
|
||||
}
|
||||
|
||||
// delegatedUser is the user a user-identity acts on behalf of.
|
||||
@@ -68,10 +58,6 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current effective identity, app, profile, and token status (JSON)",
|
||||
Long: `Show the effective app identity used by this invocation. This is not OAuth login status;
|
||||
use ` + "`lark-cli auth status --json`" + ` for OAuth user/token state.
|
||||
The JSON output includes credentialSource, appId, brand, and whether direct app credential
|
||||
env is present and matches the selected profile.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return whoamiRun(cmd, opts)
|
||||
},
|
||||
@@ -111,17 +97,7 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
// Read the cached selection computed during resolution; never re-infer it
|
||||
// here. A resolution failure (e.g. under a non-env extension provider that
|
||||
// doesn't populate a selection) degrades to the zero value rather than
|
||||
// regressing whoami's own error/diagnostic path above.
|
||||
var selection credential.IdentitySelection
|
||||
if f.Credential != nil {
|
||||
if sel, err := f.Credential.Selection(ctx); err == nil {
|
||||
selection = sel
|
||||
}
|
||||
}
|
||||
res := buildResult(cfg, as, source, diag, selection)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
@@ -146,23 +122,18 @@ func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, stri
|
||||
|
||||
// buildResult maps the resolved identity and local diagnostics into the output.
|
||||
// ResolveAs only ever returns user or bot, so the default branch handles user.
|
||||
// selection is the cached credential.IdentitySelection from resolution; it is
|
||||
// read as-is, never recomputed.
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result, selection credential.IdentitySelection) *whoamiResult {
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
|
||||
defaultAs := cfg.DefaultAs
|
||||
if defaultAs == "" {
|
||||
defaultAs = core.AsAuto
|
||||
}
|
||||
res := &whoamiResult{
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
CredentialSource: string(selection.Source),
|
||||
Explicit: selection.Explicit(),
|
||||
DirectCredentialEnv: selection.DirectCredentialEnv,
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
}
|
||||
// Use the diagnosed hint as-is: it is tailored to the credential source, so
|
||||
// it never says "auth login" when that is blocked under an external provider.
|
||||
|
||||
@@ -15,13 +15,10 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
func TestResolveSource(t *testing.T) {
|
||||
@@ -55,7 +52,7 @@ func TestBuildResult_UserValid(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
|
||||
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
@@ -80,7 +77,7 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -103,7 +100,7 @@ func TestBuildResult_BotReady(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "default_as", diag, credential.IdentitySelection{})
|
||||
r := buildResult(cfg, core.AsBot, "default_as", diag)
|
||||
|
||||
if r.Identity != "bot" || r.IdentitySource != "default_as" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
@@ -124,7 +121,7 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "auto_detect", diag, credential.IdentitySelection{})
|
||||
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -321,94 +318,3 @@ func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
|
||||
t.Fatalf("hint should explain external management: %q", got.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// noopWhoamiKeychain is a no-op KeychainAccess; the profile below uses a
|
||||
// plaintext secret, so no keychain lookup is actually required.
|
||||
type noopWhoamiKeychain struct{}
|
||||
|
||||
func (noopWhoamiKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (noopWhoamiKeychain) Set(service, account, value string) error { return nil }
|
||||
func (noopWhoamiKeychain) Remove(service, account string) error { return nil }
|
||||
|
||||
// credentialSourceSecret is the profile secret written to config for
|
||||
// TestWhoamiIncludesCredentialSource. It must never leak into whoami's output
|
||||
// (security: never leak a secret).
|
||||
const credentialSourceSecret = "test-secret"
|
||||
|
||||
// profileSelectionFactory builds a Factory whose CredentialProvider resolves
|
||||
// an explicit profile ("tenant_a") supplied via the LARKSUITE_CLI_PROFILE env
|
||||
// fallback (not --profile), so Selection().Source resolves to
|
||||
// env:LARKSUITE_CLI_PROFILE and Explicit() is true, with no direct
|
||||
// app-credential env vars present.
|
||||
func profileSelectionFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a",
|
||||
AppId: "cli_a",
|
||||
AppSecret: core.PlainSecret(credentialSourceSecret),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return noopWhoamiKeychain{} }, "tenant_a")
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{&envprovider.Provider{}}, defaultAcct, nil, nil)
|
||||
cred.WithProfileFromEnv("tenant_a")
|
||||
|
||||
cfg := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: credentialSourceSecret, Brand: core.BrandFeishu}
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
return f, out
|
||||
}
|
||||
|
||||
// TestWhoamiIncludesCredentialSource locks in the diagnostic fields surfaced
|
||||
// from the cached credential.IdentitySelection: credentialSource,
|
||||
// explicit, and directCredentialEnv. whoami must read the cached selection
|
||||
// as-is, not re-infer it.
|
||||
func TestWhoamiIncludesCredentialSource(t *testing.T) {
|
||||
f, out := profileSelectionFactory(t)
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
raw := out.String()
|
||||
if strings.Contains(raw, credentialSourceSecret) {
|
||||
t.Fatalf("whoami output leaked the profile secret: %s", raw)
|
||||
}
|
||||
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, raw)
|
||||
}
|
||||
if got.CredentialSource != string(credential.SourceEnvProfile) {
|
||||
t.Fatalf("credentialSource = %q, want %q", got.CredentialSource, credential.SourceEnvProfile)
|
||||
}
|
||||
if !got.Explicit {
|
||||
t.Fatalf("explicit = false, want true")
|
||||
}
|
||||
if got.DirectCredentialEnv.Present {
|
||||
t.Fatalf("directCredentialEnv.present = true, want false: %#v", got.DirectCredentialEnv)
|
||||
}
|
||||
if !strings.Contains(raw, `"credentialSource": "env:LARKSUITE_CLI_PROFILE"`) {
|
||||
t.Fatalf("raw JSON missing credentialSource literal: %s", raw)
|
||||
}
|
||||
if got.DirectCredentialEnv.Present || len(got.DirectCredentialEnv.Keys) != 0 ||
|
||||
got.DirectCredentialEnv.AppID != "" || got.DirectCredentialEnv.Matched || got.DirectCredentialEnv.ConflictsWithProfile {
|
||||
t.Fatalf("directCredentialEnv = %#v, want only present:false set", got.DirectCredentialEnv)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,17 +67,6 @@ Typed errors render to **stderr** as one JSON object per process exit:
|
||||
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
|
||||
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
|
||||
|
||||
Credential/identity-selection extension fields (per-Subtype-stable):
|
||||
|
||||
| Field | Carrier | Subtypes | Notes |
|
||||
|-------|---------|----------|-------|
|
||||
| `missing_keys` | `ConfigError` | `app_credential_incomplete` | env var NAMES that must all be set; never values |
|
||||
| `required_any_of` | `ConfigError` | `app_credential_incomplete` | env var NAMES where any one completes the credential; mutually exclusive with `missing_keys` |
|
||||
| `profile` | `ConfigError` | `profile_not_found`, `profile_secret_invalid` | requested profile name |
|
||||
| `app_id` | `ConfigError` | `profile_secret_invalid` | plaintext app id; never a secret |
|
||||
| `credential_source` | `ConfigError` | `profile_not_found`, `no_active_profile` | how the identity was (not) chosen: `flag:--profile` \| `env:LARKSUITE_CLI_PROFILE` \| `config` |
|
||||
| `profile_app_id`, `env_app_id` | `ValidationError` | `profile_app_credential_conflict` | the two conflicting plaintext app ids |
|
||||
|
||||
`SecurityPolicyError` renders through the same typed envelope as every
|
||||
other category. `error.type` is `"policy"`, `error.subtype` is one of
|
||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||
|
||||
@@ -136,79 +136,6 @@ func TestConfigError_MarshalJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigError_ProfileFieldsMarshalJSON(t *testing.T) {
|
||||
ce := NewConfigError(SubtypeAppCredentialIncomplete, "incomplete").
|
||||
WithMissingKeys("LARKSUITE_CLI_APP_ID", "LARKSUITE_CLI_APP_SECRET").
|
||||
WithRequiredAnyOf("LARKSUITE_CLI_APP_SECRET", "LARKSUITE_CLI_USER_ACCESS_TOKEN").
|
||||
WithProfile("work").
|
||||
WithAppID("cli_abc").
|
||||
WithCredentialSource("flag:--profile")
|
||||
b, err := json.Marshal(ce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"config"`,
|
||||
`"subtype":"app_credential_incomplete"`,
|
||||
`"missing_keys":["LARKSUITE_CLI_APP_ID","LARKSUITE_CLI_APP_SECRET"]`,
|
||||
`"required_any_of":["LARKSUITE_CLI_APP_SECRET","LARKSUITE_CLI_USER_ACCESS_TOKEN"]`,
|
||||
`"profile":"work"`,
|
||||
`"app_id":"cli_abc"`,
|
||||
`"credential_source":"flag:--profile"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// omitempty: unset fields must not appear on the wire.
|
||||
empty := NewConfigError(SubtypeProfileNotFound, "x")
|
||||
b2, err := json.Marshal(empty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2 := string(b2)
|
||||
for _, notWant := range []string{`"missing_keys"`, `"required_any_of"`, `"profile"`, `"app_id"`, `"credential_source"`} {
|
||||
if strings.Contains(s2, notWant) {
|
||||
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationError_ProfileConflictMarshalJSON(t *testing.T) {
|
||||
ve := NewValidationError(SubtypeProfileAppCredentialConflict, "conflict").
|
||||
WithProfileAppConflict("cli_profile", "cli_env")
|
||||
b, err := json.Marshal(ve)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"validation"`,
|
||||
`"subtype":"profile_app_credential_conflict"`,
|
||||
`"profile_app_id":"cli_profile"`,
|
||||
`"env_app_id":"cli_env"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// omitempty: unset conflict fields must not appear on the wire.
|
||||
empty := NewValidationError(SubtypeInvalidArgument, "x")
|
||||
b2, err := json.Marshal(empty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2 := string(b2)
|
||||
for _, notWant := range []string{`"profile_app_id"`, `"env_app_id"`} {
|
||||
if strings.Contains(s2, notWant) {
|
||||
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkError_MarshalJSON(t *testing.T) {
|
||||
ne := &NetworkError{
|
||||
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},
|
||||
|
||||
@@ -12,9 +12,8 @@ const (
|
||||
|
||||
// CategoryValidation subtypes
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeProfileAppCredentialConflict Subtype = "profile_app_credential_conflict" // profile and direct app env both set but app_id differs
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
@@ -42,13 +41,9 @@ const (
|
||||
|
||||
// CategoryConfig subtypes
|
||||
const (
|
||||
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
|
||||
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
|
||||
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
|
||||
SubtypeProfileNotFound Subtype = "profile_not_found" // --profile / LARKSUITE_CLI_PROFILE points to a nonexistent profile
|
||||
SubtypeNoActiveProfile Subtype = "no_active_profile" // no active identity input and no usable default profile
|
||||
SubtypeAppCredentialIncomplete Subtype = "app_credential_incomplete" // direct app env missing app_id or app_secret
|
||||
SubtypeProfileSecretInvalid Subtype = "profile_secret_invalid" // profile exists but its secret cannot be resolved locally
|
||||
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
|
||||
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
|
||||
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
|
||||
)
|
||||
|
||||
// CategoryNetwork subtypes
|
||||
|
||||
@@ -61,11 +61,9 @@ type TypedError interface {
|
||||
// it is intentionally not serialized.
|
||||
type ValidationError struct {
|
||||
Problem
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
ProfileAppID string `json:"profile_app_id,omitempty"`
|
||||
EnvAppID string `json:"env_app_id,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// InvalidParam is one structured validation diagnostic: the parameter that
|
||||
@@ -152,12 +150,6 @@ func (e *ValidationError) WithCause(cause error) *ValidationError {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithProfileAppConflict(profileAppID, envAppID string) *ValidationError {
|
||||
e.ProfileAppID = profileAppID
|
||||
e.EnvAppID = envAppID
|
||||
return e
|
||||
}
|
||||
|
||||
// =========================== AuthenticationError =============================
|
||||
|
||||
// AuthenticationError is the typed error for CategoryAuthentication.
|
||||
@@ -323,18 +315,8 @@ func (e *PermissionError) WithCause(cause error) *PermissionError {
|
||||
// intentionally not serialized.
|
||||
type ConfigError struct {
|
||||
Problem
|
||||
Field string `json:"field,omitempty"`
|
||||
MissingKeys []string `json:"missing_keys,omitempty"`
|
||||
RequiredAnyOf []string `json:"required_any_of,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
// CredentialSource is the machine-readable App/credential selection source
|
||||
// that produced this config error (e.g. "flag:--profile",
|
||||
// "env:LARKSUITE_CLI_PROFILE", "config"). It is required on
|
||||
// profile_not_found and no_active_profile so an agent can branch
|
||||
// on how the identity was (or was not) chosen. It is never a secret.
|
||||
CredentialSource string `json:"credential_source,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
@@ -388,34 +370,6 @@ func (e *ConfigError) WithField(field string) *ConfigError {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithMissingKeys(keys ...string) *ConfigError {
|
||||
e.MissingKeys = slices.Clone(keys)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithRequiredAnyOf(keys ...string) *ConfigError {
|
||||
e.RequiredAnyOf = slices.Clone(keys)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithProfile(name string) *ConfigError {
|
||||
e.Profile = name
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithAppID(appID string) *ConfigError {
|
||||
e.AppID = appID
|
||||
return e
|
||||
}
|
||||
|
||||
// WithCredentialSource records the machine-readable credential-selection source
|
||||
// on the wire (snake_case credential_source). The value is an enum string
|
||||
// (e.g. "flag:--profile", "config"), never a secret.
|
||||
func (e *ConfigError) WithCredentialSource(source string) *ConfigError {
|
||||
e.CredentialSource = source
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithCause(cause error) *ConfigError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
|
||||
@@ -643,29 +643,3 @@ func TestBuilderSetter_DefensiveCopy(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ======================= Profile selection error subtypes =======================
|
||||
|
||||
func TestConfigErrorProfileFields(t *testing.T) {
|
||||
e := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "incomplete").
|
||||
WithMissingKeys("LARKSUITE_CLI_APP_ID").
|
||||
WithCredentialSource("env:LARKSUITE_CLI_PROFILE")
|
||||
p, ok := errs.ProblemOf(e)
|
||||
if !ok || p.Subtype != errs.SubtypeAppCredentialIncomplete {
|
||||
t.Fatalf("subtype mismatch: %+v", p)
|
||||
}
|
||||
if len(e.MissingKeys) != 1 || e.MissingKeys[0] != "LARKSUITE_CLI_APP_ID" {
|
||||
t.Errorf("missing_keys not set: %v", e.MissingKeys)
|
||||
}
|
||||
if e.CredentialSource != "env:LARKSUITE_CLI_PROFILE" {
|
||||
t.Errorf("credential_source not set: %q", e.CredentialSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrorProfileConflict(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict, "conflict").
|
||||
WithProfileAppConflict("cli_profile", "cli_env")
|
||||
if e.ProfileAppID != "cli_profile" || e.EnvAppID != "cli_env" {
|
||||
t.Errorf("conflict fields not set: %q %q", e.ProfileAppID, e.EnvAppID)
|
||||
}
|
||||
}
|
||||
|
||||
123
extension/credential/env/env.go
vendored
123
extension/credential/env/env.go
vendored
@@ -23,89 +23,63 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
appSecret := os.Getenv(envvars.CliAppSecret)
|
||||
hasUAT := os.Getenv(envvars.CliUserAccessToken) != ""
|
||||
hasTAT := os.Getenv(envvars.CliTenantAccessToken) != ""
|
||||
presentKeys := presentCredentialEnvKeys(appID, appSecret, hasUAT, hasTAT)
|
||||
if len(presentKeys) == 0 {
|
||||
return nil, nil
|
||||
if appID == "" && appSecret == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
case hasTAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"}
|
||||
}
|
||||
if appSecret == "" && !hasUAT && !hasTAT {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
}
|
||||
}
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
|
||||
|
||||
// Identity policy variables are validated whenever a direct credential
|
||||
// input is present. Their errors must not be hidden by a later credential
|
||||
// completeness check or profile arbitration.
|
||||
defaultAs := credential.Identity(os.Getenv(envvars.CliDefaultAs))
|
||||
switch defaultAs {
|
||||
case "", credential.IdentityAuto, credential.IdentityUser, credential.IdentityBot:
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, defaultAs),
|
||||
Code: credential.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliDefaultAs,
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
}
|
||||
}
|
||||
|
||||
strictMode := os.Getenv(envvars.CliStrictMode)
|
||||
var supported credential.IdentitySupport
|
||||
switch strictMode {
|
||||
// Explicit strict mode policy takes priority
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
case "bot":
|
||||
supported = credential.SupportsBot
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
case "user":
|
||||
supported = credential.SupportsUser
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
case "off":
|
||||
supported = credential.SupportsAll
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
case "":
|
||||
// Infer from available tokens
|
||||
if hasUAT {
|
||||
supported |= credential.SupportsUser
|
||||
acct.SupportedIdentities |= credential.SupportsUser
|
||||
}
|
||||
if hasTAT {
|
||||
supported |= credential.SupportsBot
|
||||
acct.SupportedIdentities |= credential.SupportsBot
|
||||
}
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
Code: credential.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliStrictMode,
|
||||
}
|
||||
}
|
||||
|
||||
if appID == "" && appSecret == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
return nil, incompleteCredentialError(
|
||||
appID,
|
||||
envvars.CliUserAccessToken+" is set but "+envvars.CliAppID+" is missing",
|
||||
[]string{envvars.CliAppID}, nil, presentKeys)
|
||||
case hasTAT:
|
||||
return nil, incompleteCredentialError(
|
||||
appID,
|
||||
envvars.CliTenantAccessToken+" is set but "+envvars.CliAppID+" is missing",
|
||||
[]string{envvars.CliAppID}, nil, presentKeys)
|
||||
}
|
||||
}
|
||||
if appID == "" {
|
||||
return nil, incompleteCredentialError(
|
||||
appID,
|
||||
envvars.CliAppSecret+" is set but "+envvars.CliAppID+" is missing",
|
||||
[]string{envvars.CliAppID}, nil, presentKeys)
|
||||
}
|
||||
if appSecret == "" && !hasUAT && !hasTAT {
|
||||
return nil, incompleteCredentialError(
|
||||
appID,
|
||||
envvars.CliAppID+" is set but no app secret or access token is available",
|
||||
nil,
|
||||
[]string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken},
|
||||
presentKeys)
|
||||
}
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
acct := &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: appSecret,
|
||||
Brand: brand,
|
||||
DefaultAs: defaultAs,
|
||||
SupportedIdentities: supported,
|
||||
Kind: credential.AccountDirect,
|
||||
}
|
||||
|
||||
if acct.DefaultAs == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
@@ -118,35 +92,6 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
func incompleteCredentialError(appID, reason string, missingKeys, requiredAnyOf, presentKeys []string) *credential.BlockError {
|
||||
return &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: reason,
|
||||
Code: credential.BlockReasonCredentialIncomplete,
|
||||
MissingKeys: missingKeys,
|
||||
RequiredAnyOf: requiredAnyOf,
|
||||
PresentKeys: presentKeys,
|
||||
AppID: appID,
|
||||
}
|
||||
}
|
||||
|
||||
func presentCredentialEnvKeys(appID, appSecret string, hasUAT, hasTAT bool) []string {
|
||||
var keys []string
|
||||
if appID != "" {
|
||||
keys = append(keys, envvars.CliAppID)
|
||||
}
|
||||
if appSecret != "" {
|
||||
keys = append(keys, envvars.CliAppSecret)
|
||||
}
|
||||
if hasUAT {
|
||||
keys = append(keys, envvars.CliUserAccessToken)
|
||||
}
|
||||
if hasTAT {
|
||||
keys = append(keys, envvars.CliTenantAccessToken)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
var envKey string
|
||||
switch req.Type {
|
||||
|
||||
100
extension/credential/env/env_test.go
vendored
100
extension/credential/env/env_test.go
vendored
@@ -6,7 +6,6 @@ package env
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -48,22 +47,6 @@ func TestResolveAccount_OnlyIDSet(t *testing.T) {
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonCredentialIncomplete {
|
||||
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonCredentialIncomplete)
|
||||
}
|
||||
want := []string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken}
|
||||
if !slices.Equal(blockErr.RequiredAnyOf, want) {
|
||||
t.Fatalf("RequiredAnyOf = %v, want %v", blockErr.RequiredAnyOf, want)
|
||||
}
|
||||
if len(blockErr.MissingKeys) != 0 {
|
||||
t.Fatalf("MissingKeys = %v, want empty", blockErr.MissingKeys)
|
||||
}
|
||||
if !slices.Equal(blockErr.PresentKeys, []string{envvars.CliAppID}) {
|
||||
t.Fatalf("PresentKeys = %v, want [%s]", blockErr.PresentKeys, envvars.CliAppID)
|
||||
}
|
||||
if blockErr.AppID != "cli_test" {
|
||||
t.Fatalf("AppID = %q, want cli_test", blockErr.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
|
||||
@@ -92,81 +75,18 @@ func TestResolveAccount_OnlySecretSet(t *testing.T) {
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonCredentialIncomplete ||
|
||||
!slices.Equal(blockErr.MissingKeys, []string{envvars.CliAppID}) ||
|
||||
!slices.Equal(blockErr.PresentKeys, []string{envvars.CliAppSecret}) {
|
||||
t.Fatalf("BlockError = %+v, want incomplete with missing APP_ID and present APP_SECRET", blockErr)
|
||||
}
|
||||
if len(blockErr.RequiredAnyOf) != 0 {
|
||||
t.Fatalf("RequiredAnyOf = %v, want empty for APP_SECRET-only", blockErr.RequiredAnyOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{name: "UAT", key: envvars.CliUserAccessToken},
|
||||
{name: "TAT", key: envvars.CliTenantAccessToken},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv(tt.key, "token_test")
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliAppID) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonCredentialIncomplete ||
|
||||
!slices.Equal(blockErr.MissingKeys, []string{envvars.CliAppID}) ||
|
||||
!slices.Equal(blockErr.PresentKeys, []string{tt.key}) {
|
||||
t.Fatalf("BlockError = %+v, want incomplete for %s", blockErr, tt.key)
|
||||
}
|
||||
if len(blockErr.RequiredAnyOf) != 0 {
|
||||
t.Fatalf("RequiredAnyOf = %v, want empty for %s-only", blockErr.RequiredAnyOf, tt.name)
|
||||
}
|
||||
})
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidPolicyRejectedBeforeIncomplete(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{name: "DEFAULT_AS", key: envvars.CliDefaultAs},
|
||||
{name: "STRICT_MODE", key: envvars.CliStrictMode},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv(tt.key, "banana")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("error = %T %v, want BlockError", err, err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonInvalidPolicy {
|
||||
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonInvalidPolicy)
|
||||
}
|
||||
if blockErr.Param != tt.key {
|
||||
t.Fatalf("Param = %q, want %q", blockErr.Param, tt.key)
|
||||
}
|
||||
if !strings.Contains(blockErr.Reason, tt.key) {
|
||||
t.Fatalf("reason = %q, want %s", blockErr.Reason, tt.key)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(err.Error(), envvars.CliAppID) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,9 +258,6 @@ func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonInvalidPolicy || blockErr.Param != envvars.CliStrictMode {
|
||||
t.Fatalf("BlockError = %+v, want invalid_policy with Param %s", blockErr, envvars.CliStrictMode)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliStrictMode) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode)
|
||||
}
|
||||
@@ -359,9 +276,6 @@ func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonInvalidPolicy || blockErr.Param != envvars.CliDefaultAs {
|
||||
t.Fatalf("BlockError = %+v, want invalid_policy with Param %s", blockErr, envvars.CliDefaultAs)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliDefaultAs) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
|
||||
}
|
||||
|
||||
@@ -77,8 +77,6 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
Code: credential.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliDefaultAs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,8 +92,6 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
Code: credential.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliStrictMode,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
@@ -148,57 +146,6 @@ func TestResolveAccount_StrictMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidPolicyClassified(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
supportedText string
|
||||
}{
|
||||
{
|
||||
name: "default as",
|
||||
key: envvars.CliDefaultAs,
|
||||
value: "banana",
|
||||
supportedText: "want user, bot, or auto",
|
||||
},
|
||||
{
|
||||
name: "strict mode",
|
||||
key: envvars.CliStrictMode,
|
||||
value: "banana",
|
||||
supportedText: "want bot, user, or off",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliDefaultAs)
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
setEnv(t, tt.key, tt.value)
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("error = %T %v, want BlockError", err, err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonInvalidPolicy {
|
||||
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonInvalidPolicy)
|
||||
}
|
||||
if blockErr.Param != tt.key {
|
||||
t.Fatalf("Param = %q, want %q", blockErr.Param, tt.key)
|
||||
}
|
||||
if !strings.Contains(blockErr.Reason, tt.key) ||
|
||||
!strings.Contains(blockErr.Reason, tt.value) ||
|
||||
!strings.Contains(blockErr.Reason, tt.supportedText) {
|
||||
t.Fatalf("Reason = %q, want variable, invalid value, and supported values", blockErr.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_NotActive(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliAuthProxy)
|
||||
|
||||
|
||||
@@ -44,27 +44,6 @@ func (s IdentitySupport) UserOnly() bool { return s == SupportsUser }
|
||||
// BotOnly returns true if only bot identity is supported.
|
||||
func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
|
||||
|
||||
// AccountKind declares how an account participates in credential arbitration.
|
||||
type AccountKind int
|
||||
|
||||
const (
|
||||
// AccountManaged means the provider owns the whole identity; winning it
|
||||
// ends arbitration outright. The zero value, so existing providers are
|
||||
// unchanged.
|
||||
AccountManaged AccountKind = iota
|
||||
// AccountDirect marks an actively supplied raw credential (the env
|
||||
// provider's LARKSUITE_CLI_* variables). It participates in profile
|
||||
// arbitration and conflict detection instead of winning outright.
|
||||
//
|
||||
// RESERVED: only the builtin env provider may declare AccountDirect
|
||||
// today — the arbitration's direct-credential diagnostics are defined in
|
||||
// terms of the process environment, and the caller rejects AccountDirect
|
||||
// from any other provider. Third-party providers must return
|
||||
// AccountManaged until the SPI carries provider-reported input
|
||||
// descriptors.
|
||||
AccountDirect
|
||||
)
|
||||
|
||||
// Account holds resolved app credentials and configuration.
|
||||
type Account struct {
|
||||
AppID string
|
||||
@@ -74,7 +53,6 @@ type Account struct {
|
||||
ProfileName string
|
||||
OpenID string // optional; if UAT is available, API result takes precedence
|
||||
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
|
||||
Kind AccountKind // AccountManaged (default) or AccountDirect
|
||||
}
|
||||
|
||||
// Token holds a resolved access token and optional metadata.
|
||||
@@ -98,38 +76,11 @@ type TokenSpec struct {
|
||||
AppID string
|
||||
}
|
||||
|
||||
// BlockReason classifies provider-originated block conditions that callers may
|
||||
// safely map to a more specific public error contract.
|
||||
type BlockReason string
|
||||
|
||||
const (
|
||||
// BlockReasonCredentialIncomplete marks incomplete inputs from the builtin
|
||||
// process-env credential provider. It is reserved for that provider because
|
||||
// direct-credential arbitration and diagnostics currently name the fixed
|
||||
// LARKSUITE_CLI_* env surface. Third-party providers must return an
|
||||
// unclassified BlockError until the SPI carries provider-owned input
|
||||
// descriptors. Blocks without a Code propagate unchanged.
|
||||
BlockReasonCredentialIncomplete BlockReason = "credential_incomplete"
|
||||
|
||||
// BlockReasonInvalidPolicy marks a user-supplied policy input (e.g.
|
||||
// LARKSUITE_CLI_DEFAULT_AS / LARKSUITE_CLI_STRICT_MODE) that failed
|
||||
// validation. The caller maps it to a typed validation error carrying
|
||||
// Param and a repair hint, so user input mistakes never surface as
|
||||
// internal errors.
|
||||
BlockReasonInvalidPolicy BlockReason = "invalid_policy"
|
||||
)
|
||||
|
||||
// BlockError is returned by a Provider to actively reject a request
|
||||
// and prevent subsequent providers in the chain from being consulted.
|
||||
type BlockError struct {
|
||||
Provider string
|
||||
Reason string
|
||||
Code BlockReason
|
||||
MissingKeys []string // environment variable names only; never values
|
||||
RequiredAnyOf []string // environment variable names only; never values
|
||||
PresentKeys []string // environment variable names only; never values
|
||||
AppID string // plaintext app identifier used only for source comparison; never a secret
|
||||
Param string // name of the invalid input variable on invalid_policy blocks; never a value
|
||||
Provider string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *BlockError) Error() string {
|
||||
|
||||
@@ -48,18 +48,6 @@ func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.Token
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
type clientTestAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r clientTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID, Brand: core.BrandFeishu}, nil
|
||||
}
|
||||
|
||||
func newClientTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
|
||||
return credential.NewCredentialProvider(nil, clientTestAccountResolver{appID: appID}, tokenResolver, nil)
|
||||
}
|
||||
|
||||
// newTestAPIClient creates an APIClient with a mock HTTP transport.
|
||||
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
@@ -70,7 +58,7 @@ func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Bu
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(httpClient),
|
||||
)
|
||||
testCred := newClientTestCredentialProvider("test-app", &staticTokenResolver{})
|
||||
testCred := credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil)
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
return &APIClient{
|
||||
SDK: sdk,
|
||||
@@ -475,7 +463,7 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
|
||||
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Timeout: 5 * time.Millisecond},
|
||||
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -510,7 +498,7 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
|
||||
})
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Transport: rt},
|
||||
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -544,7 +532,7 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T
|
||||
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -584,7 +572,7 @@ func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.Tok
|
||||
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: newClientTestCredentialProvider("test-app", &needAuthTokenResolver{userOpenID: "ou_test_user"}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &needAuthTokenResolver{userOpenID: "ou_test_user"}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -624,7 +612,7 @@ func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *t
|
||||
func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
|
||||
@@ -27,11 +27,6 @@ import (
|
||||
// In tests, replace any field to stub out external dependencies.
|
||||
type InvocationContext struct {
|
||||
Profile string
|
||||
// ProfileFromFlag is true when Profile was set via the --profile flag,
|
||||
// and false when it came from the LARKSUITE_CLI_PROFILE env fallback
|
||||
// (or neither was set). Downstream credential resolution uses this to
|
||||
// report the correct profile source.
|
||||
ProfileFromFlag bool
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
|
||||
@@ -63,11 +63,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
// Phase 2: Credential (sole data source)
|
||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
||||
f.Credential = buildCredentialProvider(credentialDeps{
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
ProfileFromFlag: inv.ProfileFromFlag,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
|
||||
// Phase 3: Runtime config contains resolved account data only.
|
||||
@@ -175,11 +174,10 @@ func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
ProfileFromFlag bool
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
|
||||
@@ -192,13 +190,5 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
|
||||
// depend on. enrichUserInfo failures are already non-fatal (the
|
||||
// provider clears unverified identity fields), so silencing the
|
||||
// warning is safe.
|
||||
cred := credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
|
||||
if deps.Profile == "" {
|
||||
// No profile selected — don't record a phantom env source.
|
||||
return cred
|
||||
}
|
||||
if deps.ProfileFromFlag {
|
||||
return cred.WithProfileFromFlag(deps.Profile)
|
||||
}
|
||||
return cred.WithProfileFromEnv(deps.Profile)
|
||||
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
@@ -406,14 +405,6 @@ type stubExtProvider struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type stubDefaultAccountResolver struct {
|
||||
acct *credential.Account
|
||||
}
|
||||
|
||||
func (s *stubDefaultAccountResolver) ResolveAccount(_ context.Context) (*credential.Account, error) {
|
||||
return s.acct, nil
|
||||
}
|
||||
|
||||
func (s *stubExtProvider) Name() string { return s.name }
|
||||
func (s *stubExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
|
||||
return s.acct, s.err
|
||||
@@ -457,86 +448,6 @@ func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireBuiltinCredentialProvider_AllowsMatchingAppIDOnlyProfile(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliAppID, "cli_a")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a",
|
||||
AppId: "cli_a",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&envprovider.Provider{}},
|
||||
&stubDefaultAccountResolver{acct: &credential.Account{AppID: "cli_a", AppSecret: "test-secret"}},
|
||||
nil,
|
||||
nil,
|
||||
).WithProfileFromFlag("tenant_a")
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
if err := f.RequireBuiltinCredentialProvider(context.Background(), "auth"); err != nil {
|
||||
t.Fatalf("matching APP_ID-only profile should use builtin credentials: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A stale LARKSUITE_CLI_PROFILE (profile that cannot resolve) must not lock
|
||||
// the user out of the builtin setup/repair commands this gate guards: the
|
||||
// probe falls back to provider engagement and lets the command run.
|
||||
func TestRequireBuiltinCredentialProvider_StaleProfileDoesNotLockOut(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) // no config -> "ghost" cannot resolve
|
||||
|
||||
stub := &stubExtProvider{name: "env"} // not engaged: returns nil, nil
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{stub},
|
||||
&stubDefaultAccountResolver{},
|
||||
nil,
|
||||
nil,
|
||||
).WithProfileFromEnv("ghost")
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
if err := f.RequireBuiltinCredentialProvider(context.Background(), "config"); err != nil {
|
||||
t.Fatalf("stale profile must not lock out builtin auth/config commands: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An invalid policy variable (e.g. LARKSUITE_CLI_DEFAULT_AS=banana) is a user
|
||||
// input error, not an external credential takeover: the gate surfaces the
|
||||
// same typed validation error as formal arbitration instead of a misleading
|
||||
// "provided externally" refusal.
|
||||
func TestRequireBuiltinCredentialProvider_InvalidPolicySurfacesTypedError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
stub := &stubExtProvider{name: "env", err: &extcred.BlockError{
|
||||
Provider: "env",
|
||||
Reason: "invalid LARKSUITE_CLI_DEFAULT_AS \"banana\" (want user, bot, or auto)",
|
||||
Code: extcred.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliDefaultAs,
|
||||
}}
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, &stubDefaultAccountResolver{}, nil, nil)
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok || prob.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("err = %v, want typed invalid_argument (same as formal arbitration)", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "provided externally") {
|
||||
t.Fatalf("err = %v, must not read as external takeover", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = nil
|
||||
|
||||
@@ -26,6 +26,7 @@ const (
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderAgentName = "X-Agent-Name"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
@@ -55,6 +56,9 @@ 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,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// Agent headers injected 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,11 +255,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
}
|
||||
|
||||
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
||||
// invalid_config, not not_configured: the config exists but is
|
||||
// internally inconsistent. not_configured would let callers degrade
|
||||
// this into a generic "secret invalid" answer and destroy the precise
|
||||
// repair hint (which names the expected keychain key — never a value).
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "appId and appSecret keychain key are out of sync").
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
|
||||
WithHint("%s", err.Error()).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
@@ -36,13 +36,16 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
// Surface the real cause so the user can fix the broken file. Every
|
||||
// non-ENOENT load failure — malformed JSON, permission denied, I/O
|
||||
// error — means a config EXISTS but cannot be used: invalid_config.
|
||||
// Only a genuinely absent config is not_configured; anything else
|
||||
// classified as not_configured would let callers degrade it into
|
||||
// profile_not_found / no_active_profile and hide the real cause.
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
|
||||
// Surface the real cause (parse error, permission denied, etc.)
|
||||
// so the user can fix the broken file. A malformed file is
|
||||
// invalid_config; anything else (permission denied, etc.) is
|
||||
// not_configured. Both stay on the typed structured-envelope path
|
||||
// at the root command's error sink.
|
||||
subtype := errs.SubtypeNotConfigured
|
||||
if isMalformedConfigError(err) {
|
||||
subtype = errs.SubtypeInvalidConfig
|
||||
}
|
||||
return nil, errs.NewConfigError(subtype, "failed to load config: %v", err).WithCause(err)
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
return nil, NotConfiguredError()
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package credential_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
sidecarprovider "github.com/larksuite/cli/extension/credential/sidecar"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
func newRealSidecarCredentialProvider(t *testing.T) *credential.CredentialProvider {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
t.Setenv(envvars.CliProxyKey, "test-key")
|
||||
t.Setenv(envvars.CliAppID, "cli_sidecar")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliStrictMode, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
return credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&sidecarprovider.Provider{}},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func TestAuthSidecarInvalidPolicyUsesValidationContract(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{name: "default as", key: envvars.CliDefaultAs},
|
||||
{name: "strict mode", key: envvars.CliStrictMode},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cp := newRealSidecarCredentialProvider(t)
|
||||
t.Setenv(tt.key, "banana")
|
||||
|
||||
_, err := cp.ResolveAccount(context.Background())
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed validation error", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.key {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.key)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, tt.key) {
|
||||
t.Fatalf("hint = %q, want variable name %s", problem.Hint, tt.key)
|
||||
}
|
||||
var blockErr *extcred.BlockError
|
||||
if !errors.As(err, &blockErr) ||
|
||||
blockErr.Code != extcred.BlockReasonInvalidPolicy ||
|
||||
blockErr.Param != tt.key {
|
||||
t.Fatalf("cause = %T %v, want classified BlockError for %s", err, err, tt.key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSidecarGateProbeUsesValidationContract(t *testing.T) {
|
||||
cp := newRealSidecarCredentialProvider(t)
|
||||
t.Setenv(envvars.CliStrictMode, "banana")
|
||||
|
||||
name, err := cp.ActiveExtensionProviderName(context.Background())
|
||||
if name != "" {
|
||||
t.Fatalf("provider name = %q, want empty on invalid policy", name)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed validation error", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want ValidationError", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation ||
|
||||
problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
validationErr.Param != envvars.CliStrictMode {
|
||||
t.Fatalf("problem = %+v param = %q, want validation/invalid_argument param %s", problem, validationErr.Param, envvars.CliStrictMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSidecarTokenHonorsSelectedAppID(t *testing.T) {
|
||||
t.Run("matching app returns sentinel", func(t *testing.T) {
|
||||
cp := newRealSidecarCredentialProvider(t)
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT,
|
||||
AppID: "cli_sidecar",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken: %v", err)
|
||||
}
|
||||
if result == nil || result.Token != sidecar.SentinelUAT {
|
||||
t.Fatalf("result = %+v, want sidecar UAT sentinel", result)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
appID string
|
||||
}{
|
||||
{name: "empty app id", appID: ""},
|
||||
{name: "conflicting app id", appID: "cli_other"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cp := newRealSidecarCredentialProvider(t)
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT,
|
||||
AppID: tt.appID,
|
||||
})
|
||||
if result != nil {
|
||||
t.Fatalf("result = %+v, want no sidecar sentinel", result)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed internal error", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeUnknown)
|
||||
}
|
||||
if strings.Contains(err.Error(), sidecar.SentinelUAT) {
|
||||
t.Fatalf("error leaked sidecar sentinel: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// DefaultAccountResolver is implemented by the default account provider.
|
||||
@@ -142,21 +136,10 @@ type CredentialProvider struct {
|
||||
httpClient func() (*http.Client, error)
|
||||
warnOut io.Writer
|
||||
|
||||
// profile is the active profile (from --profile or LARKSUITE_CLI_PROFILE);
|
||||
// profileSrc records which of the two supplied it, for the reported
|
||||
// selection and error attribution.
|
||||
profile string
|
||||
profileSrc CredentialSourceKind
|
||||
|
||||
accountOnce sync.Once
|
||||
account *Account
|
||||
accountErr error
|
||||
selectedSource credentialSource
|
||||
// selection is the explainable credential-selection result, populated by
|
||||
// doResolveAccount under accountOnce. It never carries a secret.
|
||||
selection IdentitySelection
|
||||
|
||||
enrichOnce sync.Once
|
||||
|
||||
hintOnce sync.Once
|
||||
hint *IdentityHint
|
||||
@@ -178,521 +161,49 @@ func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
|
||||
return p
|
||||
}
|
||||
|
||||
// WithProfileFromFlag records the --profile flag value as the active profile.
|
||||
// It governs credential arbitration and the reported selection source.
|
||||
func (p *CredentialProvider) WithProfileFromFlag(profile string) *CredentialProvider {
|
||||
p.profile = profile
|
||||
p.profileSrc = SourceFlagProfile
|
||||
return p
|
||||
}
|
||||
|
||||
// WithProfileFromEnv records the LARKSUITE_CLI_PROFILE env fallback as the
|
||||
// active profile. It governs credential arbitration and the reported
|
||||
// selection source.
|
||||
func (p *CredentialProvider) WithProfileFromEnv(profile string) *CredentialProvider {
|
||||
p.profile = profile
|
||||
p.profileSrc = SourceEnvProfile
|
||||
return p
|
||||
}
|
||||
|
||||
// ResolveAccount resolves app credentials. Result is cached after first call.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
||||
// Subsequent calls return the cached result regardless of their context.
|
||||
// This is acceptable for CLI (single invocation per process) but not for long-running servers.
|
||||
func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
acct, err := p.resolveAccountSelection(ctx)
|
||||
if err != nil || acct == nil {
|
||||
return acct, err
|
||||
}
|
||||
if _, ok := p.selectedSource.(extensionTokenSource); ok {
|
||||
p.enrichOnce.Do(func() {
|
||||
p.enrichOrClearIdentity(ctx, acct, p.selectedSource)
|
||||
})
|
||||
}
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// resolveAccountSelection performs and caches only credential selection. It
|
||||
// deliberately does not resolve tokens or user_info, so callers can validate
|
||||
// the selected app before any token work begins.
|
||||
func (p *CredentialProvider) resolveAccountSelection(ctx context.Context) (*Account, error) {
|
||||
p.accountOnce.Do(func() {
|
||||
p.account, p.accountErr = p.doResolveAccount(ctx)
|
||||
})
|
||||
return p.account, p.accountErr
|
||||
}
|
||||
|
||||
// doResolveAccount arbitrates the credential/App selection in three phases:
|
||||
// gather all arbitration inputs in a single I/O pass, decide the route with a
|
||||
// pure function, then execute the remaining I/O for the chosen route.
|
||||
//
|
||||
// Resolution order (encoded in decideIdentity): a managed extension provider
|
||||
// (e.g. sidecar) wins outright; then an explicit profile (--profile /
|
||||
// LARKSUITE_CLI_PROFILE) arbitrates against the direct env credential
|
||||
// (matching app_id → profile supplies credential and tokens; mismatch → hard
|
||||
// conflict; incomplete env without a usable app_id → repair error); then a
|
||||
// complete direct env credential; then the config default (currentApp →
|
||||
// firstApp).
|
||||
//
|
||||
// It populates p.selection (never carries a secret) and p.selectedSource on
|
||||
// every success path.
|
||||
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
||||
in, err := p.gatherIdentityInputs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := decideIdentity(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acct, source, err := p.execute(ctx, d, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.selectedSource = source
|
||||
// Assigned only after full success: error paths can never leave a
|
||||
// partial selection behind.
|
||||
p.selection = d.selection
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// providerAccount pairs an extension-provider account with its token source.
|
||||
type providerAccount struct {
|
||||
acct *Account
|
||||
source extensionTokenSource
|
||||
}
|
||||
|
||||
// identityInputs is one invocation's complete arbitration input, gathered in
|
||||
// a single pass by gatherIdentityInputs. It is read-only after gathering;
|
||||
// decideIdentity consumes it without further I/O.
|
||||
type identityInputs struct {
|
||||
profile string
|
||||
profileSrc CredentialSourceKind
|
||||
|
||||
managed *providerAccount // managed extension account; wins arbitration outright
|
||||
direct *providerAccount // complete direct env credential
|
||||
// directBlock is a provider's explicit incomplete-direct-credential
|
||||
// classification (BlockError.Code == credential_incomplete). It
|
||||
// participates in profile arbitration instead of failing outright.
|
||||
directBlock *extcred.BlockError
|
||||
|
||||
// directKeys / conflictKeys describe the BUILTIN process-env direct
|
||||
// credential surface (LARKSUITE_CLI_* variable NAMES, never values).
|
||||
// They annotate DirectCredentialEnv and conflict hints; a third-party
|
||||
// AccountDirect provider reports its own inputs via BlockError metadata
|
||||
// (PresentKeys/AppID), not through these.
|
||||
directKeys []string
|
||||
conflictKeys []string
|
||||
|
||||
config *core.MultiAppConfig
|
||||
configErr error
|
||||
}
|
||||
|
||||
// gatherIdentityInputs performs the arbitration's read phase: it consults the
|
||||
// extension providers and snapshots the config. Providers classify their own
|
||||
// failures at the source (BlockError.Code); this layer must not infer them by
|
||||
// re-reading environment variables or parsing Reason.
|
||||
func (p *CredentialProvider) gatherIdentityInputs(ctx context.Context) (identityInputs, error) {
|
||||
in := identityInputs{
|
||||
profile: p.profile,
|
||||
profileSrc: p.profileSrc,
|
||||
directKeys: presentDirectCredentialKeys(),
|
||||
conflictKeys: presentDirectCredentialInputKeys(),
|
||||
}
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
var blockErr *extcred.BlockError
|
||||
if errors.As(err, &blockErr) {
|
||||
switch blockErr.Code {
|
||||
case extcred.BlockReasonCredentialIncomplete:
|
||||
// app_credential_incomplete, profile matching, and
|
||||
// DirectCredentialEnv diagnostics are defined in terms of
|
||||
// the builtin LARKSUITE_CLI_* env surface. Until the SPI
|
||||
// carries provider-owned input descriptors, accepting this
|
||||
// classification from another provider would produce
|
||||
// contradictory arbitration and repair hints.
|
||||
if _, builtin := prov.(*envprovider.Provider); !builtin {
|
||||
return in, newCredentialIncompleteProviderContractError(prov)
|
||||
}
|
||||
in.directBlock = blockErr
|
||||
case extcred.BlockReasonInvalidPolicy:
|
||||
// A user-supplied policy value failed validation; that is
|
||||
// a validation error, never an internal one.
|
||||
return in, newInvalidPolicyError(blockErr)
|
||||
default:
|
||||
// Blocks without a recognized Code preserve their
|
||||
// original attribution.
|
||||
return in, err
|
||||
return nil, err
|
||||
}
|
||||
if acct != nil {
|
||||
internal := convertAccount(acct)
|
||||
source := extensionTokenSource{provider: prov}
|
||||
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
||||
if p.warnOut != nil {
|
||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
||||
}
|
||||
break
|
||||
// enrichUserInfo failure is non-fatal: SupportedIdentities
|
||||
// (used for strict mode) is already set by the provider.
|
||||
// Clear unverified user identity for safety.
|
||||
internal.UserOpenId = ""
|
||||
internal.UserName = ""
|
||||
}
|
||||
// Any other provider error preserves its original attribution.
|
||||
return in, err
|
||||
}
|
||||
if acct == nil {
|
||||
continue
|
||||
}
|
||||
pa := &providerAccount{acct: convertAccount(acct), source: extensionTokenSource{provider: prov}}
|
||||
switch acct.Kind {
|
||||
case extcred.AccountDirect:
|
||||
// The arbitration's direct-credential surface — DirectCredentialEnv,
|
||||
// the env:LARKSUITE_CLI_APP_ID selection source, conflict-hint
|
||||
// keys — is defined in terms of the builtin process-env variables.
|
||||
// Until the SPI carries provider-reported input descriptors, only
|
||||
// the builtin env provider may declare AccountDirect; accepting it
|
||||
// from anyone else would produce self-contradictory diagnostics
|
||||
// (e.g. credentialSource "env:LARKSUITE_CLI_APP_ID" with
|
||||
// directCredentialEnv.present=false). The check is by concrete
|
||||
// type: the registry reserves neither names nor uniqueness, so a
|
||||
// Name() comparison would be forgeable.
|
||||
if _, builtin := prov.(*envprovider.Provider); !builtin {
|
||||
return in, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider %q declared AccountDirect, which is reserved for the builtin env provider", prov.Name())
|
||||
}
|
||||
in.direct = pa
|
||||
case extcred.AccountManaged:
|
||||
in.managed = pa
|
||||
default:
|
||||
return in, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider %q returned unknown AccountKind %d", prov.Name(), acct.Kind)
|
||||
}
|
||||
break // the first engaged provider ends the scan (registry priority order)
|
||||
}
|
||||
// The config snapshot backs profile lookup, the config-default route, and
|
||||
// config-default failure attribution. A winning managed or direct-env
|
||||
// identity without a profile never needs it — and managed identities must
|
||||
// keep working when the config is absent or malformed.
|
||||
if in.managed == nil && (in.profile != "" || in.direct == nil) {
|
||||
in.config, in.configErr = core.LoadOrNotConfigured()
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// credentialRoute names which source serves the selected account and tokens.
|
||||
type credentialRoute int
|
||||
|
||||
const (
|
||||
routeManaged credentialRoute = iota
|
||||
routeProfile
|
||||
routeDirectEnv
|
||||
routeConfigDefault
|
||||
)
|
||||
|
||||
// decision is decideIdentity's complete verdict. Nothing in it touched I/O.
|
||||
type decision struct {
|
||||
route credentialRoute
|
||||
selection IdentitySelection
|
||||
// profileAppID is set on routeProfile; app_id is plaintext and safe to
|
||||
// echo in the secret-invalid error.
|
||||
profileAppID string
|
||||
}
|
||||
|
||||
// decideIdentity holds every selection rule in one place: precedence
|
||||
// (managed > profile > direct env > config default), profile/direct-env
|
||||
// conflict detection, and error attribution. It is pure — same inputs, same
|
||||
// verdict — so the full selection matrix is table-testable without env vars
|
||||
// or config fixtures.
|
||||
func decideIdentity(in identityInputs) (decision, error) {
|
||||
// DirectCredentialEnv reports the direct env vars truthfully on every
|
||||
// route: Present always means "direct credential env vars are set".
|
||||
directEnv := DirectCredentialEnv{Present: len(in.directKeys) > 0, Keys: in.directKeys}
|
||||
if in.direct != nil {
|
||||
directEnv.AppID = in.direct.acct.AppID
|
||||
}
|
||||
switch {
|
||||
case in.managed != nil:
|
||||
return decision{route: routeManaged, selection: IdentitySelection{
|
||||
Source: SourceExtension(in.managed.source.Name()),
|
||||
DirectCredentialEnv: directEnv,
|
||||
}}, nil
|
||||
case in.profile != "":
|
||||
return decideProfile(in, directEnv)
|
||||
case in.directBlock != nil:
|
||||
return decision{}, newAppCredentialIncompleteError(in.directBlock, false)
|
||||
case in.direct != nil:
|
||||
return decision{route: routeDirectEnv, selection: IdentitySelection{
|
||||
Source: SourceEnvAppID,
|
||||
DirectCredentialEnv: directEnv,
|
||||
}}, nil
|
||||
default:
|
||||
return decision{route: routeConfigDefault, selection: IdentitySelection{
|
||||
Source: selectionSourceForDefault(in.config),
|
||||
DirectCredentialEnv: directEnv,
|
||||
}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// decideProfile arbitrates an explicit profile against the direct env
|
||||
// credential state.
|
||||
func decideProfile(in identityInputs, directEnv DirectCredentialEnv) (decision, error) {
|
||||
app, err := findProfile(in)
|
||||
if err != nil {
|
||||
return decision{}, err
|
||||
}
|
||||
if in.directBlock != nil {
|
||||
// APP_ID-only is sufficient to compare sources: a matching selected
|
||||
// profile supplies the credential and tokens; a mismatch is the same
|
||||
// hard conflict as a complete direct env. Anything less than a usable
|
||||
// app_id keeps the provider's repair error, extended with the
|
||||
// unset-to-use-the-profile path.
|
||||
if in.directBlock.AppID == "" || !slices.Contains(in.directBlock.PresentKeys, envvars.CliAppID) {
|
||||
return decision{}, newAppCredentialIncompleteError(in.directBlock, true)
|
||||
}
|
||||
if app.AppId != in.directBlock.AppID {
|
||||
return decision{}, newProfileAppCredentialConflict(
|
||||
in.profile, app.AppId, in.directBlock.AppID, in.directBlock.PresentKeys)
|
||||
}
|
||||
directEnv.AppID = in.directBlock.AppID
|
||||
directEnv.Matched = true
|
||||
}
|
||||
if in.direct != nil {
|
||||
// E == complete: the direct env app_id must match the profile.
|
||||
if app.AppId != in.direct.acct.AppID {
|
||||
return decision{}, newProfileAppCredentialConflict(
|
||||
in.profile, app.AppId, in.direct.acct.AppID, in.conflictKeys)
|
||||
}
|
||||
directEnv.Matched = true
|
||||
}
|
||||
return decision{
|
||||
route: routeProfile,
|
||||
selection: IdentitySelection{Source: in.profileSrc, DirectCredentialEnv: directEnv},
|
||||
profileAppID: app.AppId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// findProfile resolves the requested profile against the config snapshot.
|
||||
// A malformed config must surface its real typed cause (invalid_config):
|
||||
// reporting it as profile_not_found would send the user to `profile list`
|
||||
// and hide the broken file. Only a genuinely absent config degrades to
|
||||
// profile_not_found, because the profile then cannot exist anywhere. Both
|
||||
// deliberately outrank an incomplete direct env: fixing the profile side is
|
||||
// what makes the selected profile usable.
|
||||
func findProfile(in identityInputs) (*core.AppConfig, error) {
|
||||
if in.configErr != nil {
|
||||
if prob, ok := errs.ProblemOf(in.configErr); !ok || prob.Subtype != errs.SubtypeNotConfigured {
|
||||
return nil, in.configErr
|
||||
p.selectedSource = source
|
||||
return internal, nil
|
||||
}
|
||||
}
|
||||
if in.config != nil {
|
||||
if app := in.config.FindApp(in.profile); app != nil {
|
||||
return app, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.NewConfigError(errs.SubtypeProfileNotFound,
|
||||
"profile %q not found", in.profile).
|
||||
WithProfile(in.profile).
|
||||
WithCredentialSource(string(in.profileSrc)).
|
||||
WithHint("run `lark-cli profile list` to see available profiles.")
|
||||
}
|
||||
|
||||
// execute performs the remaining I/O for the decided route and returns the
|
||||
// account together with its token source.
|
||||
func (p *CredentialProvider) execute(ctx context.Context, d decision, in identityInputs) (*Account, credentialSource, error) {
|
||||
switch d.route {
|
||||
case routeManaged:
|
||||
return in.managed.acct, in.managed.source, nil
|
||||
case routeDirectEnv:
|
||||
return in.direct.acct, in.direct.source, nil
|
||||
case routeProfile:
|
||||
// Resolve the profile's own (keychain-backed) credential locally.
|
||||
if p.defaultAcct != nil {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
// A typed failure other than not_configured carries its own
|
||||
// precise, secret-free diagnosis (typed errors never embed secret
|
||||
// material per the error contract) — pass it through instead of
|
||||
// flattening it into the generic secret error. Untyped failures
|
||||
// and a config that vanished mid-resolution stay masked: their
|
||||
// content is not guaranteed secret-free.
|
||||
if prob, ok := errs.ProblemOf(err); ok && prob.Subtype != errs.SubtypeNotConfigured {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, nil, newProfileSecretInvalidError(in.profile, d.profileAppID)
|
||||
return nil, err
|
||||
}
|
||||
// The resolver re-reads the config; a concurrent profile edit between
|
||||
// gather and here could hand back a different app. Refuse the mismatch
|
||||
// instead of silently using credentials the arbitration never checked.
|
||||
if acct.AppID != d.profileAppID {
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"config changed during resolution: profile %q resolved to a different app", in.profile).
|
||||
WithHint("retry the command.")
|
||||
}
|
||||
return acct, defaultTokenSource{resolver: p.defaultToken}, nil
|
||||
default: // routeConfigDefault
|
||||
if p.defaultAcct == nil {
|
||||
return nil, nil, core.NotConfiguredError()
|
||||
}
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, translateConfigDefaultFailure(err, in.config)
|
||||
}
|
||||
return acct, defaultTokenSource{resolver: p.defaultToken}, nil
|
||||
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
||||
return acct, nil
|
||||
}
|
||||
}
|
||||
|
||||
// translateConfigDefaultFailure attributes a config-default failure from the
|
||||
// snapshot: a default profile that EXISTS (has an app_id) but whose secret
|
||||
// cannot be resolved locally is profile_secret_invalid — "identity is
|
||||
// configured, its secret is broken" is more actionable than "no active
|
||||
// profile". Only when there is genuinely no usable default profile do we
|
||||
// report no_active_profile. Other typed failures pass through unchanged.
|
||||
func translateConfigDefaultFailure(err error, multi *core.MultiAppConfig) error {
|
||||
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeNotConfigured {
|
||||
return err
|
||||
}
|
||||
if multi != nil {
|
||||
if app := multi.CurrentAppConfig(""); app != nil && app.AppId != "" {
|
||||
return newProfileSecretInvalidError(app.ProfileName(), app.AppId)
|
||||
}
|
||||
}
|
||||
return errs.NewConfigError(errs.SubtypeNoActiveProfile, "no active profile").
|
||||
WithCredentialSource(noActiveProfileCredentialSource).
|
||||
WithHint("run `lark-cli config init` / `lark-cli profile add`, or set %s.", envvars.CliProfile)
|
||||
}
|
||||
|
||||
func newProfileAppCredentialConflict(profile, profileAppID, envAppID string, presentKeys []string) error {
|
||||
err := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict,
|
||||
"profile %q app_id does not match %s", profile, envvars.CliAppID).
|
||||
WithProfileAppConflict(profileAppID, envAppID)
|
||||
if len(presentKeys) > 0 {
|
||||
return err.WithHint("unset %s, or select a profile whose app_id matches the environment.",
|
||||
humanList(presentKeys, "and"))
|
||||
}
|
||||
return err.WithHint("unset the direct credential environment variables, or select a profile whose app_id matches the environment.")
|
||||
}
|
||||
|
||||
func newAppCredentialIncompleteError(blockErr *extcred.BlockError, selectedProfileAvailable bool) *errs.ConfigError {
|
||||
err := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "%s", blockErr.Reason).
|
||||
WithCause(blockErr)
|
||||
if len(blockErr.MissingKeys) > 0 {
|
||||
err.WithMissingKeys(blockErr.MissingKeys...)
|
||||
}
|
||||
if len(blockErr.RequiredAnyOf) > 0 {
|
||||
err.WithRequiredAnyOf(blockErr.RequiredAnyOf...)
|
||||
}
|
||||
|
||||
hint := credentialRepairHint(blockErr)
|
||||
if selectedProfileAvailable && len(blockErr.PresentKeys) > 0 {
|
||||
hint += fmt.Sprintf(", or unset %s to use the selected profile", humanList(blockErr.PresentKeys, "and"))
|
||||
}
|
||||
return err.WithHint("%s.", hint)
|
||||
}
|
||||
|
||||
func credentialRepairHint(blockErr *extcred.BlockError) string {
|
||||
if len(blockErr.RequiredAnyOf) > 0 {
|
||||
return "set " + humanList(blockErr.RequiredAnyOf, "or")
|
||||
}
|
||||
return "set " + humanList(blockErr.MissingKeys, "and")
|
||||
}
|
||||
|
||||
func humanList(items []string, conjunction string) string {
|
||||
switch len(items) {
|
||||
case 0:
|
||||
return "the missing direct credential variables"
|
||||
case 1:
|
||||
return items[0]
|
||||
case 2:
|
||||
return items[0] + " " + conjunction + " " + items[1]
|
||||
default:
|
||||
return strings.Join(items[:len(items)-1], ", ") + ", " + conjunction + " " + items[len(items)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// newInvalidPolicyError translates a provider's invalid-policy block into the
|
||||
// typed validation contract: the failed variable name travels in param, the
|
||||
// repair path in the hint, and the original block stays on the cause chain.
|
||||
// Reason carries only the variable name and its non-secret value.
|
||||
func newInvalidPolicyError(blockErr *extcred.BlockError) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", blockErr.Reason).
|
||||
WithParam(blockErr.Param).
|
||||
WithCause(blockErr).
|
||||
WithHint("set %s to a supported value or unset it.", blockErr.Param)
|
||||
}
|
||||
|
||||
func newCredentialIncompleteProviderContractError(prov extcred.Provider) error {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider %q returned credential_incomplete, which is reserved for the builtin env provider", prov.Name())
|
||||
}
|
||||
|
||||
// newProfileSecretInvalidError is deliberately generic (SECURITY): the
|
||||
// underlying cause may carry secret material, so neither it nor its message
|
||||
// may reach the envelope. app_id is plaintext and safe to echo.
|
||||
func newProfileSecretInvalidError(profile, appID string) error {
|
||||
return errs.NewConfigError(errs.SubtypeProfileSecretInvalid,
|
||||
"profile %q credential could not be resolved locally", profile).
|
||||
WithProfile(profile).
|
||||
WithAppID(appID).
|
||||
WithHint("verify the profile's app secret or re-add the profile with `lark-cli config`.")
|
||||
}
|
||||
|
||||
// enrichOrClearIdentity verifies a provider-supplied user identity via
|
||||
// enrichUserInfo. Verification failure is non-fatal — SupportedIdentities
|
||||
// (used for strict mode) is already set by the provider — but an unverified
|
||||
// identity must not survive it: a stale OpenID would attribute calls to a
|
||||
// user the token can no longer act for.
|
||||
func (p *CredentialProvider) enrichOrClearIdentity(ctx context.Context, acct *Account, source credentialSource) {
|
||||
err := p.enrichUserInfo(ctx, acct, source)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if p.warnOut != nil {
|
||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
||||
}
|
||||
acct.UserOpenId = ""
|
||||
acct.UserName = ""
|
||||
}
|
||||
|
||||
// noActiveProfileCredentialSource is the credential_source reported on the
|
||||
// no_active_profile error. The error contract fixes this to the literal "config": there is
|
||||
// no resolved default profile at all, so the more specific config:currentApp /
|
||||
// config:firstApp source values (used on successful config-default selections)
|
||||
// would be misleading. It is an enum string, never a secret.
|
||||
const noActiveProfileCredentialSource = "config"
|
||||
|
||||
// selectionSourceForDefault reports whether the config default resolved to the
|
||||
// explicit currentApp or fell back to the first app.
|
||||
func selectionSourceForDefault(multi *core.MultiAppConfig) CredentialSourceKind {
|
||||
if multi != nil && multi.CurrentApp != "" {
|
||||
return SourceConfigCurrentApp
|
||||
}
|
||||
return SourceConfigFirstApp
|
||||
}
|
||||
|
||||
// presentDirectCredentialKeys returns the NAMES (never values) of the direct
|
||||
// app credential env vars that are set. Used to annotate DirectCredentialEnv.
|
||||
func presentDirectCredentialKeys() []string {
|
||||
var keys []string
|
||||
if os.Getenv(envvars.CliAppID) != "" {
|
||||
keys = append(keys, envvars.CliAppID)
|
||||
}
|
||||
if os.Getenv(envvars.CliAppSecret) != "" {
|
||||
keys = append(keys, envvars.CliAppSecret)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// presentDirectCredentialInputKeys returns all direct env input names that
|
||||
// must be cleared together to remove a profile/app_id conflict. Values are
|
||||
// never returned.
|
||||
func presentDirectCredentialInputKeys() []string {
|
||||
keys := presentDirectCredentialKeys()
|
||||
if os.Getenv(envvars.CliUserAccessToken) != "" {
|
||||
keys = append(keys, envvars.CliUserAccessToken)
|
||||
}
|
||||
if os.Getenv(envvars.CliTenantAccessToken) != "" {
|
||||
keys = append(keys, envvars.CliTenantAccessToken)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Selection resolves the account (once) and returns the cached, secret-free
|
||||
// explanation of how the credential/App was selected. It mirrors
|
||||
// selectedCredentialSource: resolve-then-return.
|
||||
func (p *CredentialProvider) Selection(ctx context.Context) (IdentitySelection, error) {
|
||||
if _, err := p.ResolveAccount(ctx); err != nil {
|
||||
return IdentitySelection{}, err
|
||||
}
|
||||
return p.selection, nil
|
||||
return nil, core.NotConfiguredError()
|
||||
}
|
||||
|
||||
// enrichUserInfo resolves user identity when extension provides a UAT.
|
||||
@@ -728,13 +239,17 @@ func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account,
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) {
|
||||
if _, err := p.resolveAccountSelection(ctx); err != nil {
|
||||
if p.selectedSource != nil {
|
||||
return p.selectedSource, nil
|
||||
}
|
||||
if p.defaultAcct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := p.ResolveAccount(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.selectedSource == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider resolved an account without selecting a token source").
|
||||
WithHint("retry the command.")
|
||||
return nil, fmt.Errorf("credential provider resolved an account without selecting a token source")
|
||||
}
|
||||
return p.selectedSource, nil
|
||||
}
|
||||
@@ -787,88 +302,51 @@ func (p *CredentialProvider) doResolveIdentityHint(ctx context.Context) (*Identi
|
||||
|
||||
// ResolveToken resolves an access token.
|
||||
func (p *CredentialProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
acct, err := p.resolveAccountSelection(ctx)
|
||||
source, err := p.selectedCredentialSource(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acct == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider resolved no account before %s token resolution", req.Type).
|
||||
WithHint("retry the command.")
|
||||
if source != nil {
|
||||
return resolveTokenFromSource(ctx, source, req)
|
||||
}
|
||||
source := p.selectedSource
|
||||
if source == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider resolved app %q without selecting a token source", acct.AppID).
|
||||
WithHint("retry the command.")
|
||||
|
||||
for _, prov := range p.providers {
|
||||
source := extensionTokenSource{provider: prov}
|
||||
result, found, err := source.TryResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
if req.AppID == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"TokenSpec.AppID is required for %s token resolution", req.Type).
|
||||
WithHint("retry the command.")
|
||||
source = defaultTokenSource{resolver: p.defaultToken}
|
||||
result, found, err := source.TryResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.AppID != acct.AppID {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"token requested for app %q but the selected account belongs to app %q", req.AppID, acct.AppID).
|
||||
WithHint("retry the command.")
|
||||
if found {
|
||||
return result, nil
|
||||
}
|
||||
return resolveTokenFromSource(ctx, source, req)
|
||||
return nil, &TokenUnavailableError{Type: req.Type}
|
||||
}
|
||||
|
||||
// ActiveExtensionProviderName reports whether an extension provider is managing
|
||||
// the credentials that actually win selection. With an explicit profile that
|
||||
// resolves successfully it reuses ResolveAccount's cached arbitration result;
|
||||
// otherwise it probes extension providers directly and returns the first
|
||||
// engaged provider.
|
||||
// credentials. It probes p.providers (extension providers only, not defaultAcct)
|
||||
// and returns the name of the first engaged provider.
|
||||
//
|
||||
// "Engaged" means: ResolveAccount returns a non-nil account, OR returns a
|
||||
// *extcred.BlockError (provider configured but misconfigured — still counts as
|
||||
// external). Any other probe error is propagated to the caller.
|
||||
//
|
||||
// A failed profile resolution (profile not found, broken secret, malformed
|
||||
// config, incomplete direct env, ...) deliberately does NOT propagate: this
|
||||
// probe guards the builtin setup/repair commands (auth, config), and an
|
||||
// unresolvable credential must never lock the user out of the commands that
|
||||
// fix it. It falls back to the engagement probe, which answers the only
|
||||
// question this function owns: is an extension provider holding credentials?
|
||||
// external). Any other error is propagated to the caller.
|
||||
//
|
||||
// Returns ("", nil) when no extension provider is active (built-in keychain path).
|
||||
// Safe to call multiple times: explicit-profile resolution uses sync.Once, while
|
||||
// the probe path only consults providers.
|
||||
// Safe to call multiple times — probes providers directly without the sync.Once cache.
|
||||
func (p *CredentialProvider) ActiveExtensionProviderName(ctx context.Context) (string, error) {
|
||||
// With an explicit profile, report the source that actually won the same
|
||||
// arbitration used by commands. A matching APP_ID-only env block is not an
|
||||
// external takeover once the selected profile supplies credentials/tokens.
|
||||
if p.profile != "" {
|
||||
if _, err := p.ResolveAccount(ctx); err == nil {
|
||||
if p.selectedSource == nil {
|
||||
return "", nil
|
||||
}
|
||||
if _, builtin := p.selectedSource.(defaultTokenSource); builtin {
|
||||
return "", nil
|
||||
}
|
||||
return p.selectedSource.Name(), nil
|
||||
}
|
||||
// Resolution failed — fall through to the engagement probe.
|
||||
}
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
var blockErr *extcred.BlockError
|
||||
if errors.As(err, &blockErr) {
|
||||
// Align with formal arbitration: a misconfigured policy
|
||||
// variable is the same typed validation error everywhere —
|
||||
// not an external takeover of the provider that reported it,
|
||||
// and not license to keep scanning and blame a later
|
||||
// provider instead.
|
||||
if blockErr.Code == extcred.BlockReasonInvalidPolicy {
|
||||
return "", newInvalidPolicyError(blockErr)
|
||||
}
|
||||
if blockErr.Code == extcred.BlockReasonCredentialIncomplete {
|
||||
if _, builtin := prov.(*envprovider.Provider); !builtin {
|
||||
return "", newCredentialIncompleteProviderContractError(prov)
|
||||
}
|
||||
}
|
||||
name := blockErr.Provider
|
||||
if name == "" {
|
||||
name = prov.Name()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -24,7 +23,6 @@ type mockExtProvider struct {
|
||||
err error
|
||||
accountErr error
|
||||
tokenErr error
|
||||
tokenCalls int
|
||||
}
|
||||
|
||||
func (m *mockExtProvider) Name() string { return m.name }
|
||||
@@ -35,7 +33,6 @@ func (m *mockExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account,
|
||||
return m.account, m.err
|
||||
}
|
||||
func (m *mockExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
|
||||
m.tokenCalls++
|
||||
if m.tokenErr != nil {
|
||||
return nil, m.tokenErr
|
||||
}
|
||||
@@ -52,13 +49,11 @@ func (m *mockDefaultAcct) ResolveAccount(ctx context.Context) (*Account, error)
|
||||
}
|
||||
|
||||
type mockDefaultToken struct {
|
||||
result *TokenResult
|
||||
err error
|
||||
tokenCalls int
|
||||
result *TokenResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockDefaultToken) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
m.tokenCalls++
|
||||
return m.result, m.err
|
||||
}
|
||||
|
||||
@@ -121,45 +116,35 @@ func TestCredentialProvider_AccountCached(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenFromExtension(t *testing.T) {
|
||||
for _, sourceName := range []string{"env", "authsidecar"} {
|
||||
t.Run(sourceName, func(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: sourceName,
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: sourceName},
|
||||
}},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "ext_tok" {
|
||||
t.Errorf("expected ext_tok, got %s", result.Token)
|
||||
}
|
||||
})
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: "env"},
|
||||
}},
|
||||
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "ext_tok" {
|
||||
t.Errorf("expected ext_tok, got %s", result.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenFallsToDefault(t *testing.T) {
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "skip"}},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
defaultToken, nil,
|
||||
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "default_tok" {
|
||||
t.Errorf("expected default_tok, got %s", result.Token)
|
||||
}
|
||||
if defaultToken.tokenCalls != 1 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 1", defaultToken.tokenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t *testing.T) {
|
||||
@@ -174,7 +159,7 @@ func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken() error = %v", err)
|
||||
}
|
||||
@@ -196,7 +181,7 @@ func TestCredentialProvider_SelectedSourceWithoutTokenReturnsUnavailableError(t
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want unavailable error")
|
||||
}
|
||||
@@ -217,7 +202,7 @@ func TestCredentialProvider_ResolveTokenPropagatesNonBlockExtensionError(t *test
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil || err.Error() != "provider exploded" {
|
||||
t.Fatalf("ResolveToken() error = %v, want provider exploded", err)
|
||||
}
|
||||
@@ -327,12 +312,12 @@ func TestCredentialProvider_ResolveIdentityHint_CachesResult(t *testing.T) {
|
||||
func TestCredentialProvider_ResolveTokenTreatsEmptyDefaultTokenAsMalformed(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
nil,
|
||||
&mockDefaultToken{result: &TokenResult{Token: ""}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty token") {
|
||||
t.Fatalf("ResolveToken() error = %v, want malformed empty token error", err)
|
||||
}
|
||||
@@ -425,189 +410,17 @@ func TestCredentialProvider_ResolveAccountWarnsWhenExtensionIdentityVerification
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenDoesNotBypassFailedDefaultAccountResolution(t *testing.T) {
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{err: errors.New("config unavailable")},
|
||||
defaultToken,
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil || err.Error() != "config unavailable" {
|
||||
t.Fatalf("ResolveToken() error = %v, want config unavailable", err)
|
||||
}
|
||||
if defaultToken.tokenCalls != 0 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenRejectsUnboundAppBeforeExtensionIO(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appID string
|
||||
}{
|
||||
{name: "empty app id"},
|
||||
{name: "different app id", appID: "other_app"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, sourceName := range []string{"env", "authsidecar"} {
|
||||
t.Run(tt.name+"/"+sourceName, func(t *testing.T) {
|
||||
provider := &mockExtProvider{
|
||||
name: sourceName,
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: sourceName},
|
||||
}
|
||||
httpClientCalls := 0
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{provider},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
func() (*http.Client, error) {
|
||||
httpClientCalls++
|
||||
return nil, errors.New("unexpected user_info call")
|
||||
},
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: tt.appID})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want app binding error")
|
||||
}
|
||||
assertInternalUnknownWithRetryHint(t, err)
|
||||
if provider.tokenCalls != 0 {
|
||||
t.Fatalf("extension ResolveToken() calls = %d, want 0", provider.tokenCalls)
|
||||
}
|
||||
if httpClientCalls != 0 {
|
||||
t.Fatalf("httpClient() calls = %d, want 0", httpClientCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenRejectsUnboundAppBeforeDefaultIO(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appID string
|
||||
}{
|
||||
{name: "empty app id"},
|
||||
{name: "different app id", appID: "other_app"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
defaultToken,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: tt.appID})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want app binding error")
|
||||
}
|
||||
assertInternalUnknownWithRetryHint(t, err)
|
||||
if defaultToken.tokenCalls != 0 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenRejectsNilAccountBeforeTokenIO(t *testing.T) {
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{},
|
||||
defaultToken,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "requested_app"})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want nil account error")
|
||||
}
|
||||
assertInternalUnknownWithRetryHint(t, err)
|
||||
if defaultToken.tokenCalls != 0 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenRejectsMissingSelectedSourceWithoutFallback(t *testing.T) {
|
||||
extension := &mockExtProvider{
|
||||
name: "env",
|
||||
token: &extcred.Token{Value: "ext_tok", Source: "env"},
|
||||
}
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{extension},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
defaultToken,
|
||||
nil,
|
||||
)
|
||||
cp.account = &Account{AppID: "selected_app"}
|
||||
cp.accountOnce.Do(func() {})
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "selected_app"})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want missing selected source error")
|
||||
}
|
||||
assertInternalUnknownWithRetryHint(t, err)
|
||||
if extension.tokenCalls != 0 {
|
||||
t.Fatalf("extension ResolveToken() calls = %d, want 0", extension.tokenCalls)
|
||||
}
|
||||
if defaultToken.tokenCalls != 0 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenMatchingExtensionDoesNotEnrichIdentity(t *testing.T) {
|
||||
provider := &mockExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: "env"},
|
||||
}
|
||||
httpClientCalls := 0
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{provider},
|
||||
nil,
|
||||
nil,
|
||||
func() (*http.Client, error) {
|
||||
httpClientCalls++
|
||||
return nil, errors.New("unexpected user_info call")
|
||||
},
|
||||
)
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken() error = %v", err)
|
||||
}
|
||||
if result.Token != "ext_tok" {
|
||||
t.Fatalf("ResolveToken() token = %q, want %q", result.Token, "ext_tok")
|
||||
}
|
||||
if provider.tokenCalls != 1 {
|
||||
t.Fatalf("extension ResolveToken() calls = %d, want 1", provider.tokenCalls)
|
||||
}
|
||||
if httpClientCalls != 0 {
|
||||
t.Fatalf("httpClient() calls = %d, want 0", httpClientCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func assertInternalUnknownWithRetryHint(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed internal error", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("error problem = %+v, want internal/unknown", problem)
|
||||
}
|
||||
if problem.Hint != "retry the command." {
|
||||
t.Fatalf("error hint = %q, want retry hint", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveExtensionProviderName_ExtActive(t *testing.T) {
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// stubDecideProvider satisfies extcred.Provider for building providerAccount
|
||||
// literals; decideIdentity only ever calls Name() on it.
|
||||
type stubDecideProvider struct{ name string }
|
||||
|
||||
func (s stubDecideProvider) Name() string { return s.name }
|
||||
func (s stubDecideProvider) Priority() int { return 0 }
|
||||
func (s stubDecideProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s stubDecideProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func pa(providerName, appID string) *providerAccount {
|
||||
return &providerAccount{
|
||||
acct: &Account{AppID: appID},
|
||||
source: extensionTokenSource{provider: stubDecideProvider{name: providerName}},
|
||||
}
|
||||
}
|
||||
|
||||
func appIDOnlyBlock(appID string) *extcred.BlockError {
|
||||
return &extcred.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
Code: extcred.BlockReasonCredentialIncomplete,
|
||||
RequiredAnyOf: []string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken},
|
||||
PresentKeys: []string{envvars.CliAppID},
|
||||
AppID: appID,
|
||||
}
|
||||
}
|
||||
|
||||
func uatOnlyBlock() *extcred.BlockError {
|
||||
return &extcred.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing",
|
||||
Code: extcred.BlockReasonCredentialIncomplete,
|
||||
MissingKeys: []string{envvars.CliAppID},
|
||||
PresentKeys: []string{envvars.CliUserAccessToken},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecideIdentity exercises the selection matrix as data: decideIdentity is
|
||||
// pure, so every rule (precedence, conflict detection, error attribution) is
|
||||
// table-testable without env vars or config fixtures.
|
||||
func TestDecideIdentity(t *testing.T) {
|
||||
tenantA := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{Name: "tenant_a", AppId: "cli_a"}},
|
||||
}
|
||||
noCurrent := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{Name: "tenant_a", AppId: "cli_a"}},
|
||||
}
|
||||
invalidConfigErr := errs.NewConfigError(errs.SubtypeInvalidConfig, "invalid config format")
|
||||
notConfiguredErr := core.NotConfiguredError()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in identityInputs
|
||||
route credentialRoute
|
||||
source CredentialSourceKind
|
||||
matched bool
|
||||
subtype errs.Subtype // "" = success expected
|
||||
}{
|
||||
{
|
||||
name: "managed provider wins over explicit profile",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, managed: pa("sidecar", "sidecar_app"), config: tenantA},
|
||||
route: routeManaged,
|
||||
source: SourceExtension("sidecar"),
|
||||
},
|
||||
{
|
||||
name: "profile conflicts with complete direct env app_id",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, direct: pa("env", "cli_x"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}, config: tenantA},
|
||||
subtype: errs.SubtypeProfileAppCredentialConflict,
|
||||
},
|
||||
{
|
||||
name: "matched complete direct env yields profile route",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceEnvProfile, direct: pa("env", "cli_a"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}, config: tenantA},
|
||||
route: routeProfile,
|
||||
source: SourceEnvProfile,
|
||||
matched: true,
|
||||
},
|
||||
{
|
||||
name: "APP_ID-only block matching the profile yields profile route",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}, config: tenantA},
|
||||
route: routeProfile,
|
||||
source: SourceFlagProfile,
|
||||
matched: true,
|
||||
},
|
||||
{
|
||||
name: "APP_ID-only block mismatching the profile is a hard conflict",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: appIDOnlyBlock("cli_x"), directKeys: []string{envvars.CliAppID}, config: tenantA},
|
||||
subtype: errs.SubtypeProfileAppCredentialConflict,
|
||||
},
|
||||
{
|
||||
name: "UAT-only block with a valid profile keeps the repair error",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: uatOnlyBlock(), config: tenantA},
|
||||
subtype: errs.SubtypeAppCredentialIncomplete,
|
||||
},
|
||||
{
|
||||
name: "block without profile is app_credential_incomplete",
|
||||
in: identityInputs{directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}},
|
||||
subtype: errs.SubtypeAppCredentialIncomplete,
|
||||
},
|
||||
{
|
||||
name: "complete direct env without profile wins",
|
||||
in: identityInputs{direct: pa("env", "cli_env"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}},
|
||||
route: routeDirectEnv,
|
||||
source: SourceEnvAppID,
|
||||
},
|
||||
{
|
||||
name: "malformed config is not masked as profile_not_found",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, configErr: invalidConfigErr},
|
||||
subtype: errs.SubtypeInvalidConfig,
|
||||
},
|
||||
{
|
||||
name: "absent config degrades to profile_not_found",
|
||||
in: identityInputs{profile: "ghost", profileSrc: SourceEnvProfile, configErr: notConfiguredErr},
|
||||
subtype: errs.SubtypeProfileNotFound,
|
||||
},
|
||||
{
|
||||
name: "profile missing from a valid config is profile_not_found even with incomplete env",
|
||||
in: identityInputs{profile: "ghost", profileSrc: SourceEnvProfile, directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}, config: tenantA},
|
||||
subtype: errs.SubtypeProfileNotFound,
|
||||
},
|
||||
{
|
||||
name: "config default reports currentApp",
|
||||
in: identityInputs{config: tenantA},
|
||||
route: routeConfigDefault,
|
||||
source: SourceConfigCurrentApp,
|
||||
},
|
||||
{
|
||||
name: "config default without currentApp reports firstApp",
|
||||
in: identityInputs{config: noCurrent},
|
||||
route: routeConfigDefault,
|
||||
source: SourceConfigFirstApp,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d, err := decideIdentity(tc.in)
|
||||
if tc.subtype != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("decideIdentity = %+v, want error subtype %q", d, tc.subtype)
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok || prob.Subtype != tc.subtype {
|
||||
t.Fatalf("error = %v, want subtype %q", err, tc.subtype)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("decideIdentity: %v", err)
|
||||
}
|
||||
if d.route != tc.route {
|
||||
t.Errorf("route = %d, want %d", d.route, tc.route)
|
||||
}
|
||||
if d.selection.Source != tc.source {
|
||||
t.Errorf("source = %q, want %q", d.selection.Source, tc.source)
|
||||
}
|
||||
if d.selection.DirectCredentialEnv.Matched != tc.matched {
|
||||
t.Errorf("matched = %v, want %v", d.selection.DirectCredentialEnv.Matched, tc.matched)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -74,13 +74,9 @@ func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string
|
||||
|
||||
func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
// Load config once — used for both credentials and strict mode.
|
||||
// LoadOrNotConfigured distinguishes an absent config (→ not_configured)
|
||||
// from a malformed/unreadable one (→ invalid_config with cause), so a
|
||||
// broken config is never masked as "run config init" — matching the
|
||||
// explicit-profile path in doResolveAccount.
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, core.NotConfiguredError()
|
||||
}
|
||||
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
||||
@@ -120,7 +116,6 @@ type DefaultTokenProvider struct {
|
||||
|
||||
tatOnce sync.Once
|
||||
tatResult *TokenResult
|
||||
tatAppID string
|
||||
tatErr error
|
||||
}
|
||||
|
||||
@@ -131,42 +126,21 @@ func NewDefaultTokenProvider(defaultAcct *DefaultAccountProvider, httpClient fun
|
||||
func (p *DefaultTokenProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
switch req.Type {
|
||||
case TokenTypeUAT:
|
||||
return p.resolveUAT(ctx, req)
|
||||
return p.resolveUAT(ctx)
|
||||
case TokenTypeTAT:
|
||||
return p.resolveTAT(ctx, req)
|
||||
return p.resolveTAT(ctx)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported token type: %s", req.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTokenAppID refuses to hand out a token for a different app than the
|
||||
// caller resolved. The token provider re-reads the config, so a concurrent
|
||||
// profile edit between account resolution and token resolution could otherwise
|
||||
// cross tokens between apps. TokenSpec.AppID is REQUIRED here: an empty value
|
||||
// would silently disable the guarantee, so it is rejected rather than skipped.
|
||||
func checkTokenAppID(req TokenSpec, resolvedAppID string) error {
|
||||
if req.AppID == "" {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"TokenSpec.AppID is required for %s token resolution", req.Type)
|
||||
}
|
||||
if req.AppID == resolvedAppID {
|
||||
return nil
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"config changed during resolution: token requested for app %q but the saved profile now resolves to a different app", req.AppID).
|
||||
WithHint("retry the command.")
|
||||
}
|
||||
|
||||
// resolveUAT resolves a user access token. Not cached (unlike TAT) because UAT
|
||||
// may be refreshed between calls and GetValidAccessToken handles its own caching.
|
||||
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, error) {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkTokenAppID(req, acct.AppID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient, err := p.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -183,36 +157,20 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context, req TokenSpec) (*
|
||||
return &TokenResult{Token: token, Scopes: scopes}, nil
|
||||
}
|
||||
|
||||
// resolveTAT resolves a tenant access token. The result is cached after the
|
||||
// first mint via sync.Once — only the context from that call is used.
|
||||
//
|
||||
// The account is resolved and checked against the request BEFORE any token
|
||||
// work: a mismatched request must not trigger a token mint (network call,
|
||||
// quota, audit trail) for the wrong app. The cached result is additionally
|
||||
// re-checked on every hit, so a token minted for one app is never served to
|
||||
// a request that resolved another.
|
||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
// resolveTAT resolves a tenant access token. The result is cached after the first
|
||||
// call via sync.Once — only the context from the first call is used.
|
||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
p.tatOnce.Do(func() {
|
||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
|
||||
})
|
||||
return p.tatResult, p.tatErr
|
||||
}
|
||||
|
||||
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkTokenAppID(req, acct.AppID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.tatOnce.Do(func() {
|
||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx, acct)
|
||||
p.tatAppID = acct.AppID
|
||||
})
|
||||
if p.tatErr != nil {
|
||||
return nil, p.tatErr
|
||||
}
|
||||
if err := checkTokenAppID(req, p.tatAppID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.tatResult, nil
|
||||
}
|
||||
|
||||
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context, acct *Account) (*TokenResult, error) {
|
||||
httpClient, err := p.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -4,15 +4,10 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestDefaultTokenProvider_Dispatches(t *testing.T) {
|
||||
@@ -97,136 +92,3 @@ func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
|
||||
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTokenAppID(t *testing.T) {
|
||||
if err := checkTokenAppID(TokenSpec{Type: TokenTypeUAT}, "cli_a"); err == nil {
|
||||
t.Fatal("empty requested app must be rejected: it would silently disable the guarantee")
|
||||
}
|
||||
if err := checkTokenAppID(TokenSpec{AppID: "cli_a"}, "cli_a"); err != nil {
|
||||
t.Fatalf("matching app must pass: %v", err)
|
||||
}
|
||||
err := checkTokenAppID(TokenSpec{AppID: "cli_a"}, "cli_b")
|
||||
if err == nil {
|
||||
t.Fatal("mismatched app must be refused")
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("error type = %T, want *errs.InternalError", err)
|
||||
}
|
||||
}
|
||||
|
||||
// REAL-path regression for review F2: the token provider re-reads the config,
|
||||
// so a profile edit between account resolution and token resolution must not
|
||||
// hand a token minted for the new app to a caller that resolved the old one.
|
||||
// Uses the real DefaultAccountProvider + DefaultTokenProvider; the HTTP stub
|
||||
// makes the network step unreachable, so reaching it proves the app check ran
|
||||
// and passed first.
|
||||
func TestDefaultTokenProvider_RefusesTokenAfterConfigSwap(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeCfg := func(appID string) {
|
||||
t.Helper()
|
||||
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
|
||||
Name: "tenant_a", AppId: appID, AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
}
|
||||
writeCfg("cli_a")
|
||||
|
||||
httpSentinel := errors.New("http client sentinel: unreachable in test")
|
||||
tp := NewDefaultTokenProvider(
|
||||
NewDefaultAccountProvider(nil, "tenant_a"),
|
||||
func() (*http.Client, error) { return nil, httpSentinel },
|
||||
nil,
|
||||
)
|
||||
|
||||
// Matching app: the consistency check passes and resolution proceeds to
|
||||
// the (stubbed) HTTP step.
|
||||
_, err := tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "cli_a"})
|
||||
if !errors.Is(err, httpSentinel) {
|
||||
t.Fatalf("err = %v, want the HTTP sentinel (check must pass for a matching app)", err)
|
||||
}
|
||||
|
||||
// The profile now resolves to a different app: the token request that was
|
||||
// arbitrated for cli_a must be refused before any token work happens.
|
||||
writeCfg("cli_b")
|
||||
_, err = tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "cli_a"})
|
||||
if err == nil || !strings.Contains(err.Error(), "config changed during resolution") {
|
||||
t.Fatalf("err = %v, want config-changed refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// F1 regression: a TAT request for a mismatched app must be refused BEFORE
|
||||
// any token work starts — no HTTP client construction, no mint, no cache —
|
||||
// otherwise the CLI mints (and caches) a token for the wrong app and only
|
||||
// then refuses to return it, leaving auth audit/quota side effects behind.
|
||||
func TestDefaultTokenProvider_TATChecksAppBeforeAnyTokenWork(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
|
||||
Name: "tenant_a", AppId: "cli_b", AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
httpCalled := false
|
||||
tp := NewDefaultTokenProvider(
|
||||
NewDefaultAccountProvider(nil, "tenant_a"),
|
||||
func() (*http.Client, error) { httpCalled = true; return nil, errors.New("http sentinel") },
|
||||
nil,
|
||||
)
|
||||
|
||||
// The profile resolves to cli_b, but the caller arbitrated cli_a.
|
||||
_, err := tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeTAT, AppID: "cli_a"})
|
||||
if err == nil || !strings.Contains(err.Error(), "config changed during resolution") {
|
||||
t.Fatalf("err = %v, want config-changed refusal", err)
|
||||
}
|
||||
if httpCalled {
|
||||
t.Fatal("token work started for a mismatched app: the check must run before any HTTP client is built")
|
||||
}
|
||||
}
|
||||
|
||||
// countingTATTripper serves a canned successful TAT response and counts calls.
|
||||
type countingTATTripper struct{ calls int }
|
||||
|
||||
func (c *countingTATTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
c.calls++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":0,"access_token":"your-access-token"}`)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TAT happy path: the first request mints the token over HTTP, the second is
|
||||
// served from the sync.Once cache without another HTTP call.
|
||||
func TestDefaultTokenProvider_TATSuccessAndCacheHit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
|
||||
Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
tripper := &countingTATTripper{}
|
||||
tp := NewDefaultTokenProvider(
|
||||
NewDefaultAccountProvider(nil, "tenant_a"),
|
||||
func() (*http.Client, error) { return &http.Client{Transport: tripper}, nil },
|
||||
nil,
|
||||
)
|
||||
|
||||
req := TokenSpec{Type: TokenTypeTAT, AppID: "cli_a"}
|
||||
first, err := tp.ResolveToken(context.Background(), req)
|
||||
if err != nil || first.Token != "your-access-token" {
|
||||
t.Fatalf("first resolve = %+v, %v; want minted token", first, err)
|
||||
}
|
||||
second, err := tp.ResolveToken(context.Background(), req)
|
||||
if err != nil || second.Token != "your-access-token" {
|
||||
t.Fatalf("second resolve = %+v, %v; want cached token", second, err)
|
||||
}
|
||||
if tripper.calls != 1 {
|
||||
t.Fatalf("HTTP calls = %d, want exactly 1 (second resolve must hit the cache)", tripper.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
// CredentialSourceKind is the wire-stable App/credential selection source.
|
||||
type CredentialSourceKind string
|
||||
|
||||
const (
|
||||
SourceFlagProfile CredentialSourceKind = "flag:--profile"
|
||||
SourceEnvProfile CredentialSourceKind = "env:LARKSUITE_CLI_PROFILE"
|
||||
SourceEnvAppID CredentialSourceKind = "env:LARKSUITE_CLI_APP_ID"
|
||||
SourceConfigCurrentApp CredentialSourceKind = "config:currentApp"
|
||||
SourceConfigFirstApp CredentialSourceKind = "config:firstApp"
|
||||
|
||||
// SourceExtensionPrefix prefixes the name of a managed extension provider
|
||||
// that won selection outright (e.g. "extension:sidecar"). With it, an
|
||||
// empty Source is left with exactly one meaning: not resolved.
|
||||
SourceExtensionPrefix CredentialSourceKind = "extension:"
|
||||
)
|
||||
|
||||
// SourceExtension reports the selection source for a managed extension
|
||||
// provider by name.
|
||||
func SourceExtension(name string) CredentialSourceKind {
|
||||
return SourceExtensionPrefix + CredentialSourceKind(name)
|
||||
}
|
||||
|
||||
// DirectCredentialEnv describes the state of direct app credential env vars.
|
||||
// It never carries a secret value — only names and the non-sensitive app_id.
|
||||
type DirectCredentialEnv struct {
|
||||
Present bool `json:"present"`
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
AppID string `json:"appId,omitempty"`
|
||||
Matched bool `json:"matched,omitempty"`
|
||||
ConflictsWithProfile bool `json:"conflictsWithProfile,omitempty"`
|
||||
}
|
||||
|
||||
// IdentitySelection is the explainable result of credential selection.
|
||||
// It carries NO secret value.
|
||||
type IdentitySelection struct {
|
||||
Source CredentialSourceKind
|
||||
DirectCredentialEnv DirectCredentialEnv
|
||||
}
|
||||
|
||||
// Explicit reports whether the identity was actively specified by the
|
||||
// user/agent (flag or env), which governs no-fallback behavior.
|
||||
func (s IdentitySelection) Explicit() bool {
|
||||
switch s.Source {
|
||||
case SourceFlagProfile, SourceEnvProfile, SourceEnvAppID:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIdentitySelectionExplicit(t *testing.T) {
|
||||
cases := []struct {
|
||||
src CredentialSourceKind
|
||||
explicit bool
|
||||
}{
|
||||
{SourceFlagProfile, true},
|
||||
{SourceEnvProfile, true},
|
||||
{SourceEnvAppID, true},
|
||||
{SourceConfigCurrentApp, false},
|
||||
{SourceConfigFirstApp, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
sel := IdentitySelection{Source: c.src}
|
||||
if sel.Explicit() != c.explicit {
|
||||
t.Errorf("source %q: Explicit()=%v want %v", c.src, sel.Explicit(), c.explicit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,24 +52,6 @@ func TestFullChain_EnvWins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullChain_EnvRejectsDifferentApp(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env_app")
|
||||
t.Setenv(envvars.CliAppSecret, "env_secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "env_uat")
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&envprovider.Provider{}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT, AppID: "other_app",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want app binding error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullChain_Fallthrough(t *testing.T) {
|
||||
// env provider returns nil (no env vars set), falls through to default token
|
||||
ep := &envprovider.Provider{}
|
||||
@@ -77,8 +59,7 @@ func TestFullChain_Fallthrough(t *testing.T) {
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
&mockDefaultAccountProvider{account: &credential.Account{AppID: "app1"}},
|
||||
mock, nil,
|
||||
nil, mock, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT, AppID: "app1",
|
||||
@@ -91,14 +72,6 @@ func TestFullChain_Fallthrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type mockDefaultAccountProvider struct {
|
||||
account *credential.Account
|
||||
}
|
||||
|
||||
func (m *mockDefaultAccountProvider) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return m.account, nil
|
||||
}
|
||||
|
||||
type mockDefaultTokenProvider struct {
|
||||
token string
|
||||
scopes string
|
||||
|
||||
@@ -21,7 +21,6 @@ const (
|
||||
|
||||
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||
CliProfile = "LARKSUITE_CLI_PROFILE"
|
||||
|
||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"
|
||||
|
||||
@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, agentName)
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, " "+agentName+" ")
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.77",
|
||||
"version": "1.0.80",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.77",
|
||||
"version": "1.0.80",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.77",
|
||||
"version": "1.0.80",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
|
||||
if grep -Fq 'run.name !== "CI"' "$workflow"; then
|
||||
echo "semantic-review must not use the dynamic workflow run name as workflow identity" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_in_step "$summary_verify_step" 'github.rest.actions.getWorkflow' "PR quality summary must resolve static workflow metadata"
|
||||
require_in_step "$summary_verify_step" 'workflow.name !== "CI"' "PR quality summary must verify the static workflow name"
|
||||
require_in_step "$summary_verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "PR quality summary must verify the static workflow path"
|
||||
require_in_step "$summary_verify_step" 'run.path && run.path !== workflow.path' "PR quality summary must reject workflow path metadata mismatches"
|
||||
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
|
||||
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
|
||||
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
|
||||
@@ -201,7 +209,10 @@ require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR qualit
|
||||
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
|
||||
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
|
||||
|
||||
require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
|
||||
require_in_step "$verify_step" 'github.rest.actions.getWorkflow' "semantic-review must resolve static workflow metadata"
|
||||
require_in_step "$verify_step" 'workflow.name !== "CI"' "semantic-review must verify the static workflow name"
|
||||
require_in_step "$verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "semantic-review must verify the static workflow path"
|
||||
require_in_step "$verify_step" 'run.path && run.path !== workflow.path' "semantic-review must reject workflow path metadata mismatches"
|
||||
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
|
||||
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
|
||||
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -250,7 +251,8 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
"total": 2,
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "发票抬头", "required": false, "description": nil,
|
||||
"visible_rule": map[string]interface{}{"logic": "and", "conditions": []interface{}{[]interface{}{"q_001", "==", "是"}}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -258,9 +260,14 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// The list output must forward visible_rule verbatim so agents can read existing display conditions.
|
||||
if !strings.Contains(got, `"visible_rule"`) {
|
||||
t.Fatalf("visible_rule missing from list output: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
@@ -296,11 +303,49 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
t.Fatalf("expected error for invalid questions JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visible_rule passthrough", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_new1", "title": "发票抬头"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
rule, ok := body.Questions[0]["visible_rule"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
if rule["logic"] != "and" {
|
||||
t.Fatalf("visible_rule logic not preserved: %#v", rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
@@ -311,15 +356,29 @@ func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`}
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true,"visible_rule":{"logic":"and","conditions":[["q_002","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// visible_rule must be forwarded verbatim to the API (transcribe faithfully).
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
if _, ok := body.Questions[0]["visible_rule"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteDelete(t *testing.T) {
|
||||
|
||||
@@ -25,14 +25,21 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -25,14 +25,26 @@ var BaseFormQuestionsUpdate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Update uses full question overwrite semantics: omitted/empty fields are written as defaults/empty, so run +form-questions-list first and include existing values you want to keep. Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id; pass null or omit to clear). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Update uses full question overwrite semantics, not a patch.",
|
||||
"Run +form-questions-list first and include existing title/description/required/option_display_mode/visible_rule values you want to keep.",
|
||||
"Omitted fields reset to defaults; empty strings, null, and empty arrays are written as empty/clear when accepted by the API.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -783,6 +783,20 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question create visible_rule",
|
||||
shortcut: BaseFormQuestionsCreate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question update visible_rule",
|
||||
shortcut: BaseFormQuestionsUpdate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search json",
|
||||
shortcut: BaseRecordSearch,
|
||||
@@ -1028,6 +1042,39 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsUpdateHelpGuidesFullOverwrite(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFormQuestionsUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
wantHelp := []string{
|
||||
"Update uses full question overwrite semantics",
|
||||
"run +form-questions-list first",
|
||||
"include existing values you want to keep",
|
||||
"pass null or omit to clear",
|
||||
}
|
||||
for _, want := range wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
"full question overwrite semantics, not a patch",
|
||||
"Run +form-questions-list first",
|
||||
"title/description/required/option_display_mode/visible_rule",
|
||||
"Omitted fields reset to defaults",
|
||||
"empty strings, null, and empty arrays are written as empty/clear",
|
||||
}
|
||||
for _, want := range wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -250,7 +250,7 @@ var CalendarAgenda = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
backfillDescriptionRich(e)
|
||||
collapseDescription(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 descriptionRich := descriptionRichToSend(runtime); descriptionRich != "" {
|
||||
eventData["description_rich"] = descriptionRich
|
||||
if description := descriptionToSend(runtime); description != "" {
|
||||
eventData["description_rich"] = description
|
||||
}
|
||||
return eventData
|
||||
}
|
||||
@@ -120,8 +120,7 @@ var CalendarCreate = common.Shortcut{
|
||||
{Name: "summary", Desc: "event title"},
|
||||
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
|
||||
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
|
||||
{Name: "description", Desc: "deprecated: plain-text description; use --description-rich (Markdown) instead", Hidden: true},
|
||||
{Name: "description-rich", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; 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: "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)"},
|
||||
@@ -234,7 +233,7 @@ var CalendarCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
if err := resolveDescriptionRichImages(runtime, calendarId); err != nil {
|
||||
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
|
||||
if status, _ := out["status"].(string); status != "cancelled" {
|
||||
delete(out, "status")
|
||||
}
|
||||
backfillDescriptionRich(out)
|
||||
collapseDescription(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -988,8 +988,9 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured patch body: %v", err)
|
||||
}
|
||||
// The deprecated, hidden --description folds into description_rich; the CLI
|
||||
// never sends the plain description field (mutually exclusive downstream).
|
||||
// --description is the unified field, treated as rich text and sent as
|
||||
// description_rich; the CLI never sends the plain description field
|
||||
// (mutually exclusive downstream).
|
||||
if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" {
|
||||
t.Fatalf("unexpected patch body: %#v", body)
|
||||
}
|
||||
@@ -1411,18 +1412,17 @@ func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Read keeps both fields: description (plain) and description_rich (rich).
|
||||
if !strings.Contains(out, "\"description\": \"[测试]\\n友情提醒\"") {
|
||||
t.Errorf("expected plain description retained, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"description_rich\": \"友情提醒\"") {
|
||||
t.Errorf("expected rich description surfaced, got: %s", out)
|
||||
// Read exposes a single unified description field: it carries the rich
|
||||
// (Markdown) value when present, and the plain text otherwise. The internal
|
||||
// description_rich key is never surfaced.
|
||||
if !strings.Contains(out, "\"description\": \"友情提醒\"") {
|
||||
t.Errorf("expected rich value surfaced under description, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"description\": \"just text\"") {
|
||||
t.Errorf("expected plain description retained for plain-only event, got: %s", out)
|
||||
t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"description_rich\": \"just text\"") {
|
||||
t.Errorf("expected description_rich backfilled from plain, got: %s", out)
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3438,7 +3438,8 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGet_UnifiesDescriptionRich(t *testing.T) {
|
||||
// Read keeps both description (plain) and description_rich (rich).
|
||||
// Read exposes a single unified description field carrying the rich value
|
||||
// when present, and the plain text otherwise; description_rich is dropped.
|
||||
t.Run("rich present", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -3462,17 +3463,16 @@ func TestGet_UnifiesDescriptionRich(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "\"description\": \"[表格]\"") {
|
||||
t.Errorf("expected plain description retained, got: %s", out)
|
||||
if !strings.Contains(out, "| a | b |") {
|
||||
t.Errorf("expected rich value surfaced under description, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"description_rich\":") {
|
||||
t.Errorf("expected description_rich in output, got: %s", out)
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
// When only a plain description exists, description_rich is backfilled from it
|
||||
// and the plain description is still returned.
|
||||
t.Run("only plain backfills rich", func(t *testing.T) {
|
||||
// When only a plain description exists, it is surfaced under description.
|
||||
t.Run("only plain surfaces under description", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
@@ -3495,10 +3495,10 @@ func TestGet_UnifiesDescriptionRich(t *testing.T) {
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "\"description\": \"just text\"") {
|
||||
t.Errorf("expected plain description retained, got: %s", out)
|
||||
t.Errorf("expected plain description surfaced, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"description_rich\": \"just text\"") {
|
||||
t.Errorf("expected description_rich backfilled from plain, got: %s", out)
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ var CalendarUpdate = common.Shortcut{
|
||||
{Name: "event-id", Desc: "event ID to update", Required: true},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "summary", Desc: "event title"},
|
||||
{Name: "description", Desc: "deprecated: plain-text description; use --description-rich (Markdown) instead", Hidden: true},
|
||||
{Name: "description-rich", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; 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: "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: "start", Desc: "new start time (ISO 8601); requires --end"},
|
||||
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
|
||||
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
||||
@@ -72,7 +71,7 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if !hasCalendarUpdateOperation(runtime) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --description-rich, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -114,10 +113,7 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
|
||||
body["summary"] = runtime.Str("summary")
|
||||
hasFields = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("description-rich") {
|
||||
body["description_rich"] = runtime.Str("description-rich")
|
||||
hasFields = true
|
||||
} else if runtime.Cmd.Flags().Changed("description") {
|
||||
if runtime.Cmd.Flags().Changed("description") {
|
||||
body["description_rich"] = runtime.Str("description")
|
||||
hasFields = true
|
||||
}
|
||||
@@ -362,11 +358,8 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
|
||||
}
|
||||
|
||||
// Upload any local images referenced in --description-rich and rewrite them
|
||||
// to drive URLs before the description is sent (the service cannot read
|
||||
// local files).
|
||||
if runtime.Cmd.Flags().Changed("description-rich") {
|
||||
if err := resolveDescriptionRichImages(runtime, calendarID); err != nil {
|
||||
if runtime.Cmd.Flags().Changed("description") {
|
||||
if err := resolveDescriptionImages(runtime, calendarID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -443,19 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
|
||||
if summary, _ := event["summary"].(string); summary != "" {
|
||||
result["summary"] = summary
|
||||
}
|
||||
// Surface both description fields on read: description holds plain text,
|
||||
// description_rich holds the rich (Markdown) version, backfilled from plain
|
||||
// when the service returned no rich value.
|
||||
description, _ := event["description"].(string)
|
||||
if description != "" {
|
||||
result["description"] = description
|
||||
}
|
||||
descriptionRich, _ := event["description_rich"].(string)
|
||||
if descriptionRich == "" {
|
||||
descriptionRich = description
|
||||
}
|
||||
if descriptionRich != "" {
|
||||
result["description_rich"] = descriptionRich
|
||||
if rich, _ := event["description_rich"].(string); rich != "" {
|
||||
result["description"] = rich
|
||||
} else if plain, _ := event["description"].(string); plain != "" {
|
||||
result["description"] = plain
|
||||
}
|
||||
if start := formatCalendarEventTime(event["start_time"]); start != "" {
|
||||
result["start"] = start
|
||||
|
||||
@@ -27,8 +27,8 @@ const calendarMediaParentType = "calendar"
|
||||
|
||||
var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
|
||||
|
||||
func resolveDescriptionRichImages(runtime *common.RuntimeContext, calendarID string) error {
|
||||
md := runtime.Str("description-rich")
|
||||
func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
|
||||
md := runtime.Str("description")
|
||||
if md == "" || !strings.Contains(md, "",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr != nil {
|
||||
@@ -233,7 +233,7 @@ func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--description-rich", "",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr != nil {
|
||||
@@ -253,7 +253,7 @@ func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path
|
||||
// yields a typed --description-rich validation error before any API call.
|
||||
// yields a typed --description validation error before any API call.
|
||||
func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
@@ -263,7 +263,7 @@ func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--description-rich", "",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr == nil {
|
||||
@@ -273,7 +273,7 @@ func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
|
||||
if !errors.As(runErr, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr)
|
||||
}
|
||||
if ve.Param != "--description-rich" {
|
||||
t.Errorf("param = %q, want --description-rich", ve.Param)
|
||||
if ve.Param != "--description" {
|
||||
t.Errorf("param = %q, want --description", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,20 +30,23 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
|
||||
return startInput, endInput
|
||||
}
|
||||
|
||||
func backfillDescriptionRich(event map[string]interface{}) {
|
||||
func collapseDescription(event map[string]interface{}) {
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
if descRich, _ := event["description_rich"].(string); descRich == "" {
|
||||
if desc, _ := event["description"].(string); desc != "" {
|
||||
event["description_rich"] = desc
|
||||
}
|
||||
rich, _ := event["description_rich"].(string)
|
||||
plain, _ := event["description"].(string)
|
||||
delete(event, "description_rich")
|
||||
switch {
|
||||
case rich != "":
|
||||
event["description"] = rich
|
||||
case plain != "":
|
||||
event["description"] = plain
|
||||
default:
|
||||
delete(event, "description")
|
||||
}
|
||||
}
|
||||
func descriptionRichToSend(runtime *common.RuntimeContext) string {
|
||||
if v := runtime.Str("description-rich"); v != "" {
|
||||
return v
|
||||
}
|
||||
func descriptionToSend(runtime *common.RuntimeContext) string {
|
||||
return runtime.Str("description")
|
||||
}
|
||||
|
||||
|
||||
@@ -25,18 +25,6 @@ func (r *scopeCheckTokenResolver) ResolveToken(ctx context.Context, req credenti
|
||||
return r.result, r.err
|
||||
}
|
||||
|
||||
type scopeCheckAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r scopeCheckAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID}, nil
|
||||
}
|
||||
|
||||
func newScopeCheckCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
|
||||
return credential.NewCredentialProvider(nil, scopeCheckAccountResolver{appID: appID}, tokenResolver, nil)
|
||||
}
|
||||
|
||||
// TestEnhancePermissionError_TypedPermissionErrorRouted pins typed routing:
|
||||
// an *errs.PermissionError gets enhanced regardless of its Message text,
|
||||
// decoupling this helper from canonical-message rewrites that would
|
||||
@@ -118,7 +106,7 @@ func TestEnhancePermissionError_PermissionErrorGetsScopeHint(t *testing.T) {
|
||||
|
||||
func TestCheckShortcutScopes_PropagatesContextCancellation(t *testing.T) {
|
||||
f := &cmdutil.Factory{
|
||||
Credential: newScopeCheckCredentialProvider("app-1", &scopeCheckTokenResolver{err: context.Canceled}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{err: context.Canceled}, nil),
|
||||
}
|
||||
|
||||
err := checkShortcutScopes(f, context.Background(), core.AsUser, &core.CliConfig{AppID: "app-1"}, []string{"im:message:read"})
|
||||
@@ -136,9 +124,9 @@ func TestCheckShortcutScopes_PropagatesContextCancellation(t *testing.T) {
|
||||
// command for human consumers.
|
||||
func TestCheckShortcutScopes_ReturnsTypedPermissionError(t *testing.T) {
|
||||
f := &cmdutil.Factory{
|
||||
Credential: newScopeCheckCredentialProvider("app-1", &scopeCheckTokenResolver{
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{
|
||||
result: &credential.TokenResult{Token: "t", Scopes: "im:message:read calendar:calendar:read"},
|
||||
}),
|
||||
}, nil),
|
||||
}
|
||||
|
||||
required := []string{"im:message:read", "drive:drive:read", "docx:document:read"}
|
||||
@@ -180,7 +168,7 @@ func TestCheckShortcutScopes_ReturnsTypedPermissionError(t *testing.T) {
|
||||
|
||||
func TestCheckShortcutScopes_IgnoresNonContextTokenErrors(t *testing.T) {
|
||||
f := &cmdutil.Factory{
|
||||
Credential: newScopeCheckCredentialProvider("app-1", &scopeCheckTokenResolver{err: errors.New("token cache unavailable")}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{err: errors.New("token cache unavailable")}, nil),
|
||||
}
|
||||
|
||||
err := checkShortcutScopes(f, context.Background(), core.AsUser, &core.CliConfig{AppID: "app-1"}, []string{"im:message:read"})
|
||||
|
||||
325
shortcuts/drive/drive_member_list.go
Normal file
325
shortcuts/drive/drive_member_list.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// 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 "-"
|
||||
}
|
||||
426
shortcuts/drive/drive_member_list_test.go
Normal file
426
shortcuts/drive/drive_member_list_test.go
Normal file
@@ -0,0 +1,426 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
241
shortcuts/drive/drive_permission_get_setting.go
Normal file
241
shortcuts/drive/drive_permission_get_setting.go
Normal file
@@ -0,0 +1,241 @@
|
||||
// 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
|
||||
},
|
||||
}
|
||||
438
shortcuts/drive/drive_permission_get_setting_test.go
Normal file
438
shortcuts/drive/drive_permission_get_setting_test.go
Normal file
@@ -0,0 +1,438 @@
|
||||
// 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,18 +33,6 @@ func (r *driveStatusScopedTokenResolver) ResolveToken(ctx context.Context, req c
|
||||
return &credential.TokenResult{Token: "test-token", Scopes: r.scopes}, nil
|
||||
}
|
||||
|
||||
type driveTestAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r driveTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID}, nil
|
||||
}
|
||||
|
||||
func newDriveTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
|
||||
return credential.NewCredentialProvider(nil, driveTestAccountResolver{appID: appID}, tokenResolver, nil)
|
||||
}
|
||||
|
||||
// TestDriveStatusCategorizesByHash exercises the four-bucket classification
|
||||
// against a real walk of the temp dir and a mocked Drive listing.
|
||||
func TestDriveStatusCategorizesByHash(t *testing.T) {
|
||||
@@ -320,7 +308,7 @@ func TestDriveStatusQuickMarksUntrustedTimestampAsModified(t *testing.T) {
|
||||
// requiring drive:file:download even after quick mode made download optional.
|
||||
func TestDriveStatusExactRejectsMissingDownloadScope(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
f.Credential = newDriveTestCredentialProvider(driveTestConfig().AppID, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"})
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
@@ -369,7 +357,7 @@ func TestDriveStatusExactRejectsMissingDownloadScope(t *testing.T) {
|
||||
// blocked on the exact-mode download scope precheck.
|
||||
func TestDriveStatusQuickAcceptsMissingDownloadScope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
f.Credential = newDriveTestCredentialProvider(driveTestConfig().AppID, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"})
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -1592,7 +1593,7 @@ func TestDriveSyncAskConflictEOFDuringPlanningPreventsAnyWrites(t *testing.T) {
|
||||
|
||||
func TestDriveSyncDryRunQuickAcceptsMetadataOnlyScope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
f.Credential = newDriveTestCredentialProvider(driveTestConfig().AppID, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"})
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
@@ -1621,7 +1622,7 @@ func TestDriveSyncPreflightsActionScopesBeforeListing(t *testing.T) {
|
||||
AppID: "drive-sync-download-scope-only", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, syncTestConfig)
|
||||
f.Credential = newDriveTestCredentialProvider(syncTestConfig.AppID, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly drive:file:download"})
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly drive:file:download"}, nil)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
@@ -329,7 +329,7 @@ func newDriveTaskResultRuntimeWithScopes(t *testing.T, as core.Identity, scopes
|
||||
|
||||
cfg := driveTestConfig()
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = newDriveTestCredentialProvider(cfg.AppID, &mockDriveTaskResultTokenResolver{scopes: scopes})
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockDriveTaskResultTokenResolver{scopes: scopes}, nil)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, as)
|
||||
runtime.Factory = factory
|
||||
@@ -919,7 +919,7 @@ func TestValidateDriveTaskResultScopesPropagatesContextCancellation(t *testing.T
|
||||
|
||||
cfg := driveTestConfig()
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = newDriveTestCredentialProvider(cfg.AppID, cancelingTokenResolver{})
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, cancelingTokenResolver{}, nil)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, core.AsUser)
|
||||
runtime.Factory = factory
|
||||
|
||||
@@ -32,6 +32,8 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveMemberAdd,
|
||||
DriveMemberList,
|
||||
DrivePermissionGetSetting,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
|
||||
@@ -39,6 +39,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+member-add",
|
||||
"+member-list",
|
||||
"+permission-get-setting",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
|
||||
@@ -27,14 +27,6 @@ func (s *staticConvertlibTokenResolver) ResolveToken(_ context.Context, _ creden
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
type convertlibTestAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r convertlibTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID}, nil
|
||||
}
|
||||
|
||||
type convertlibRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f convertlibRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
@@ -76,12 +68,7 @@ func newBotConvertlibRuntime(t *testing.T, rt http.RoundTripper) *common.Runtime
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
testCred := credential.NewCredentialProvider(
|
||||
nil,
|
||||
convertlibTestAccountResolver{appID: cfg.AppID},
|
||||
&staticConvertlibTokenResolver{},
|
||||
nil,
|
||||
)
|
||||
testCred := credential.NewCredentialProvider(nil, nil, &staticConvertlibTokenResolver{}, nil)
|
||||
runtime := &common.RuntimeContext{
|
||||
Config: cfg,
|
||||
Factory: &cmdutil.Factory{
|
||||
|
||||
@@ -37,18 +37,6 @@ func (s *staticShortcutTokenResolver) ResolveToken(_ context.Context, _ credenti
|
||||
return &credential.TokenResult{Token: "tenant-token"}, nil
|
||||
}
|
||||
|
||||
type imTestAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r imTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID}, nil
|
||||
}
|
||||
|
||||
func newIMTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
|
||||
return credential.NewCredentialProvider(nil, imTestAccountResolver{appID: appID}, tokenResolver, nil)
|
||||
}
|
||||
|
||||
type shortcutRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f shortcutRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
@@ -102,7 +90,7 @@ func newBotShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeCo
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
testCred := newIMTestCredentialProvider(cfg.AppID, &staticShortcutTokenResolver{})
|
||||
testCred := credential.NewCredentialProvider(nil, nil, &staticShortcutTokenResolver{}, nil)
|
||||
runtime := &common.RuntimeContext{
|
||||
Config: cfg,
|
||||
Factory: &cmdutil.Factory{
|
||||
|
||||
@@ -288,12 +288,12 @@ func (r errorTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSp
|
||||
|
||||
func setRuntimeScopes(t *testing.T, rt *common.RuntimeContext, scopes string) {
|
||||
t.Helper()
|
||||
rt.Factory.Credential = newIMTestCredentialProvider(rt.Config.AppID, scopedTokenResolver{scopes: scopes})
|
||||
rt.Factory.Credential = credential.NewCredentialProvider(nil, nil, scopedTokenResolver{scopes: scopes}, nil)
|
||||
}
|
||||
|
||||
func setRuntimeTokenError(t *testing.T, rt *common.RuntimeContext, err error) {
|
||||
t.Helper()
|
||||
rt.Factory.Credential = newIMTestCredentialProvider(rt.Config.AppID, errorTokenResolver{err: err})
|
||||
rt.Factory.Credential = credential.NewCredentialProvider(nil, nil, errorTokenResolver{err: err}, nil)
|
||||
}
|
||||
|
||||
func TestFlagMessageID(t *testing.T) {
|
||||
|
||||
@@ -17,9 +17,10 @@ import (
|
||||
)
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
|
||||
// legacy synthetic-token prefix or a 28-character token whose interleaved
|
||||
// product/region marker is "OFL0X". The backend requires
|
||||
// "office_sheet_file" for those imported spreadsheets.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
@@ -27,22 +28,37 @@ const (
|
||||
localOfficePrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officePrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
// officePrefixes are the legacy synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry.
|
||||
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping either the
|
||||
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
|
||||
// "office_sheet_file".
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
func isOfficeSpreadsheet(spreadsheetToken string) bool {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(spreadsheetToken) != 28 {
|
||||
return false
|
||||
}
|
||||
// The five-character marker occupies positions 5, 10, 15, 20, and 25
|
||||
// (1-based) in the interleaved token.
|
||||
marker := []byte{
|
||||
spreadsheetToken[4],
|
||||
spreadsheetToken[9],
|
||||
spreadsheetToken[14],
|
||||
spreadsheetToken[19],
|
||||
spreadsheetToken[24],
|
||||
}
|
||||
return string(marker) == "OFL0X"
|
||||
}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken.
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
if isOfficeSpreadsheet(spreadsheetToken) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "fake_office_abc123",
|
||||
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
|
||||
"--file", "img.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
@@ -117,10 +117,10 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
|
||||
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"office_sheet_file"`) {
|
||||
t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out)
|
||||
t.Fatalf("dry-run should include parent_type=office_sheet_file for interleaved OFL0X token, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"sheet_image"`) {
|
||||
t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out)
|
||||
t.Fatalf("dry-run must not emit sheet_image for interleaved OFL0X token, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported
|
||||
// "office" spreadsheet (token prefixed with "fake_office_") uploads with
|
||||
// "office" spreadsheet (token carrying the interleaved "OFL0X" marker) uploads with
|
||||
// parent_type=office_sheet_file instead of the native sheet_image.
|
||||
func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
@@ -259,7 +259,7 @@ func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
const officeToken = "fake_office_abc123"
|
||||
const officeToken = "aaaaOaaaaFaaaaLaaaa0aaaaXaaa"
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", officeToken,
|
||||
|
||||
@@ -53,9 +53,10 @@ func sheetsInputStatError(flag string, err error) error {
|
||||
}
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
|
||||
// legacy synthetic-token prefix or a 28-character token whose interleaved
|
||||
// product/region marker is "OFL0X". The backend requires
|
||||
// "office_sheet_file" for those imported spreadsheets.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
@@ -63,21 +64,38 @@ const (
|
||||
localOfficePrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officePrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
// officePrefixes are the legacy synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry.
|
||||
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
|
||||
|
||||
func isOfficeSpreadsheet(spreadsheetToken string) bool {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(spreadsheetToken) != 28 {
|
||||
return false
|
||||
}
|
||||
// The five-character marker occupies positions 5, 10, 15, 20, and 25
|
||||
// (1-based) in the interleaved token.
|
||||
marker := []byte{
|
||||
spreadsheetToken[4],
|
||||
spreadsheetToken[9],
|
||||
spreadsheetToken[14],
|
||||
spreadsheetToken[19],
|
||||
spreadsheetToken[24],
|
||||
}
|
||||
return string(marker) == "OFL0X"
|
||||
}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken. It is the single
|
||||
// place that maps a spreadsheet token to its parent_type so every image-upload
|
||||
// entry point (and its dry-run preview) stays consistent.
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
if isOfficeSpreadsheet(spreadsheetToken) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ import (
|
||||
|
||||
// TestSheetMediaParentType pins the token→parent_type mapping that every
|
||||
// sheets image-upload entry point funnels through. Native spreadsheet tokens
|
||||
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
|
||||
// "local_office_" synthetic token and must upload with "office_sheet_file".
|
||||
// use "sheet_image"; imported "office" spreadsheets use either a legacy
|
||||
// prefix or the interleaved "OFL0X" marker and must upload with
|
||||
// "office_sheet_file".
|
||||
func TestSheetMediaParentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -40,6 +41,13 @@ func TestSheetMediaParentType(t *testing.T) {
|
||||
{"fake_office token, only the prefix", fakeOfficePrefix, officeSheetFileParentType},
|
||||
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
|
||||
{"local_office token, only the prefix", localOfficePrefix, officeSheetFileParentType},
|
||||
{"interleaved OFL0X office token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
|
||||
{"interleaved exlcn token", "abcdeefghxijkllmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved shtcn native token", "abcdsefghhijkltmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved pptcn token", "abcdpefghpijkltmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved wodcn token", "abcdwefghoijkldmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved OFL0X marker with short length", "aaaaOaaaaFaaaaLaaaa0aaaaXaa", sheetImageParentType},
|
||||
{"interleaved OFL0X marker with long length", "aaaaOaaaaFaaaaLaaaa0aaaaXaaaa", sheetImageParentType},
|
||||
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
|
||||
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
|
||||
}
|
||||
@@ -57,7 +65,7 @@ func TestSheetMediaParentType(t *testing.T) {
|
||||
// to end (the Execute path the dry-run tests don't reach), asserting the
|
||||
// parent_type that actually goes out on the wire is derived from the token: a
|
||||
// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet
|
||||
// (fake_office_-prefixed token) as office_sheet_file.
|
||||
// (legacy prefix or interleaved OFL0X marker) as office_sheet_file.
|
||||
func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -67,6 +75,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
|
||||
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
|
||||
{"interleaved OFL0X imported spreadsheet", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -78,7 +79,7 @@ func newWikiDeleteSpaceRuntimeWithScopes(t *testing.T, as core.Identity, scopes
|
||||
|
||||
cfg := wikiTestConfig()
|
||||
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = newWikiTestCredentialProvider(cfg.AppID, &mockWikiMoveTokenResolver{scopes: scopes})
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockWikiMoveTokenResolver{scopes: scopes}, nil)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +delete-space"}, cfg, as)
|
||||
runtime.Factory = factory
|
||||
|
||||
@@ -27,18 +27,6 @@ type fakeWikiMoveNodeCall struct {
|
||||
Spec wikiMoveSpec
|
||||
}
|
||||
|
||||
type wikiTestAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r wikiTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID}, nil
|
||||
}
|
||||
|
||||
func newWikiTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
|
||||
return credential.NewCredentialProvider(nil, wikiTestAccountResolver{appID: appID}, tokenResolver, nil)
|
||||
}
|
||||
|
||||
type fakeWikiDocsToWikiMoveCall struct {
|
||||
TargetSpaceID string
|
||||
Spec wikiMoveSpec
|
||||
@@ -155,7 +143,7 @@ func newWikiMoveRuntimeWithScopes(t *testing.T, as core.Identity, scopes string)
|
||||
|
||||
cfg := wikiTestConfig()
|
||||
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = newWikiTestCredentialProvider(cfg.AppID, &mockWikiMoveTokenResolver{scopes: scopes})
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockWikiMoveTokenResolver{scopes: scopes}, nil)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +move"}, cfg, as)
|
||||
runtime.Factory = factory
|
||||
|
||||
@@ -26,13 +26,12 @@ import (
|
||||
|
||||
// fakeExtProvider is a stub extcred.Provider for tests that returns a fixed token.
|
||||
type fakeExtProvider struct {
|
||||
appID string
|
||||
token string
|
||||
}
|
||||
|
||||
func (f *fakeExtProvider) Name() string { return "fake" }
|
||||
func (f *fakeExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
|
||||
return &extcred.Account{AppID: f.appID}, nil
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return &extcred.Token{Value: f.token, Source: "fake"}, nil
|
||||
@@ -382,7 +381,7 @@ func TestProxyHandler_AcceptsAllowedAuthHeaders(t *testing.T) {
|
||||
// Use a handler with a real (fake) credential provider so we can
|
||||
// distinguish auth-header reject (403) from later failures.
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{appID: "cli_test", token: "real-token"}},
|
||||
[]extcred.Provider{&fakeExtProvider{token: "real-token"}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
h := &proxyHandler{
|
||||
@@ -502,7 +501,7 @@ func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
|
||||
upstreamHost := strings.TrimPrefix(upstream.URL, "https://")
|
||||
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{appID: "cli_test", token: realToken}},
|
||||
[]extcred.Provider{&fakeExtProvider{token: realToken}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
|
||||
@@ -57,12 +57,12 @@ metadata:
|
||||
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) |
|
||||
| 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 |
|
||||
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);其余配置先 get 现状,再按返回结构更新 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md)(filter 条件结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md));其余配置先 get 现状,再按返回结构更新 |
|
||||
| 一次性聚合统计 | `+data-query` | 必读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 和入口 [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md);完整 DSL 再读 [lark-base-data-query.md](references/lark-base-data-query.md) |
|
||||
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
|
||||
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 |
|
||||
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
|
||||
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 |
|
||||
@@ -116,6 +116,7 @@ metadata:
|
||||
## 表单与视图细节
|
||||
|
||||
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- `+form-questions-update` 是题目配置全量覆盖,不是 patch;未传字段会回落默认值,传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
|
||||
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。
|
||||
- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
|
||||
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
|
||||
@@ -146,13 +147,14 @@ metadata:
|
||||
## 保留 Reference
|
||||
|
||||
- [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP
|
||||
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT
|
||||
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT;`+data-query` 的 `filters` 结构是独立对象 DSL,不使用公共 tuple filter 协议
|
||||
- [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造
|
||||
- [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造
|
||||
- [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段
|
||||
- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充
|
||||
- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释
|
||||
- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON
|
||||
- [lark-base-filter-condition.md](references/lark-base-filter-condition.md):视图 filter、记录 `--filter-json`、表单 `visible_rule` 的 tuple 条件结构公共协议 SSOT;不适用于 `+data-query`
|
||||
- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON
|
||||
- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议
|
||||
- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md):workflow 入口与 steps JSON SSOT
|
||||
|
||||
179
skills/lark-base/references/lark-base-filter-condition.md
Normal file
179
skills/lark-base/references/lark-base-filter-condition.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Base Filter 条件结构(公共协议)
|
||||
|
||||
Filter 是一组「字段/操作符/值」条件的组合,用 `logic`(`and` / `or`)把多条 `conditions` 连接起来,用于描述「满足什么条件」。视图筛选 `filter`、记录读取/搜索的 `--filter-json`、表单题目显隐条件 `visible_rule` 复用同一套 tuple 结构,本文件是其公共协议(SSOT)。
|
||||
|
||||
## 0. 适用范围
|
||||
|
||||
本协议只适用于以下场景:
|
||||
|
||||
- `+view-set-filter` / `+view-get-filter` 的视图筛选配置。
|
||||
- `+record-list --filter-json` / `+record-search --filter-json` 的结构化记录筛选。
|
||||
- `+form-questions-create` / `+form-questions-update` 中的 `visible_rule` 显隐条件。
|
||||
|
||||
本协议**不适用于 `+data-query`**。`+data-query` 支持过滤,但使用的是 LiteQuery DSL 的 `filters` 对象结构:`{"type":1,"conjunction":"and","conditions":[{"field_name":"状态","operator":"is","value":["有效"]}]}`,不是这里的 tuple 条件 `["状态","==","有效"]`。构造 `+data-query --dsl` 时请阅读 [lark-base-data-query.md](lark-base-data-query.md) 的 FilterGroup / Condition 章节。
|
||||
|
||||
## 1. 顶层结构
|
||||
|
||||
- 必须是 JSON 对象。
|
||||
- 顶层结构是 `{logic?, conditions?}`。
|
||||
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`。
|
||||
- `conditions` 默认空数组。
|
||||
- 每条条件写成 tuple:`[field, operator, value?]`。
|
||||
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]`、`[field, "non_empty"]`。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["状态", "intersects", ["Doing"]],
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]],
|
||||
["截止时间", "empty"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
清空写法:
|
||||
|
||||
```json
|
||||
{
|
||||
"conditions": []
|
||||
}
|
||||
```
|
||||
|
||||
## 2. operator
|
||||
|
||||
可用 operator:
|
||||
- `==`
|
||||
- `!=`
|
||||
- `>`
|
||||
- `>=`
|
||||
- `<`
|
||||
- `<=`
|
||||
- `intersects`
|
||||
- `disjoint`
|
||||
- `empty`
|
||||
- `non_empty`
|
||||
|
||||
## 3. value 写法
|
||||
|
||||
value 类型取决于条件引用对象(字段 / 题目)的类型。
|
||||
|
||||
### `text`
|
||||
|
||||
用字符串:
|
||||
|
||||
```json
|
||||
["标题", "intersects", "发布"]
|
||||
```
|
||||
|
||||
### `location`
|
||||
|
||||
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
|
||||
|
||||
```json
|
||||
["位置", "intersects", "深圳"]
|
||||
```
|
||||
|
||||
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
|
||||
|
||||
### `number` / `auto_number`
|
||||
|
||||
用数字:
|
||||
|
||||
```json
|
||||
["工时", ">=", 3.5]
|
||||
```
|
||||
|
||||
### `select`
|
||||
|
||||
用选项名数组:
|
||||
|
||||
```json
|
||||
["状态", "intersects", ["Doing", "Blocked"]]
|
||||
```
|
||||
|
||||
### `user` / `created_by` / `updated_by`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id:`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
|
||||
|
||||
```json
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]]
|
||||
```
|
||||
|
||||
### `group_chat`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
|
||||
|
||||
```json
|
||||
["负责群", "intersects", [{ "id": "oc_xxx" }]]
|
||||
```
|
||||
|
||||
### `link`
|
||||
|
||||
用记录 id 对象数组:
|
||||
|
||||
```json
|
||||
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
|
||||
```
|
||||
|
||||
### `checkbox`
|
||||
|
||||
用布尔值:
|
||||
|
||||
```json
|
||||
["完成", "==", true]
|
||||
```
|
||||
|
||||
### `datetime` / `created_at` / `updated_at`
|
||||
|
||||
用相对时间关键字或 `ExactDate(...)`:
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "Today"]
|
||||
```
|
||||
|
||||
可用关键字:
|
||||
- `Today`
|
||||
- `Yesterday`
|
||||
- `Tomorrow`
|
||||
|
||||
### `formula` / `lookup`
|
||||
|
||||
- 筛选值类型由字段计算结果类型动态决定。
|
||||
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
|
||||
- 如果报错,再按错误提示把 `value` 改成对应类型。
|
||||
|
||||
字符串示例:
|
||||
|
||||
```json
|
||||
["风险说明", "intersects", "高风险"]
|
||||
```
|
||||
|
||||
数字示例:
|
||||
|
||||
```json
|
||||
["汇总分", ">=", 80]
|
||||
```
|
||||
|
||||
## 4. 易错点
|
||||
|
||||
- 不要再写旧对象风格:`{"field_name":...,"operator":...}`。
|
||||
- `user` / `group_chat` / `link` 不要写成单个标量。
|
||||
- `empty` / `non_empty` 不要硬塞无意义的 value。
|
||||
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
||||
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前配置或字段定义,或根据错误提示修正类型。
|
||||
|
||||
## 5. 参考
|
||||
- [lookup-field-guide.md](lookup-field-guide.md)
|
||||
@@ -19,10 +19,7 @@ lark-cli base +form-questions-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[
|
||||
{"type":"text","title":"您的姓名是?","required":true},
|
||||
{"type":"text","title":"您的联系方式是?","required":false}
|
||||
]'
|
||||
--questions '[{"type":"text","title":"您的姓名是?","required":true},{"type":"text","title":"您的联系方式是?","required":false}]'
|
||||
|
||||
# 添加单选题(带选项)
|
||||
lark-cli base +form-questions-create \
|
||||
@@ -50,6 +47,13 @@ lark-cli base +form-questions-create \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"type":"text","title":"反馈建议","description":"更多详情请查看[帮助文档](https://example.com/help)"}]'
|
||||
|
||||
# 添加带显隐条件(visible_rule)的问题:当「是否需要发票」选择「是」时才显示「发票抬头」
|
||||
lark-cli base +form-questions-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"type":"select","title":"是否需要发票","required":true,"options":[{"name":"是","hue":"Blue"},{"name":"否","hue":"Gray"}]},{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -78,6 +82,7 @@ lark-cli base +form-questions-create \
|
||||
| `multiple` | 否 | 是否多选(`select`/`user` 类型有效,bool) |
|
||||
| `options` | 否 | 选项列表(仅 `select` 有效):`[{"name":"选项1","hue":"Blue"}]`,hue 可选:`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Purple`/`Gray` |
|
||||
| `style` | 否 | 字段样式配置(见下方说明) |
|
||||
| `visible_rule` | 否 | 题目显隐条件(见下方「`visible_rule` 显隐条件」) |
|
||||
|
||||
### `style` 字段说明
|
||||
|
||||
@@ -88,6 +93,30 @@ lark-cli base +form-questions-create \
|
||||
| `number`(评分) | `{"type":"rating","icon":"star","min":1,"max":5}` | icon 可选:`star`/`heart`/`thumbsup`/`fire`/`smile`/`lightning`/`flower`/`number` |
|
||||
| `datetime` | `{"format":"yyyy/MM/dd"}` | format 可选:`yyyy/MM/dd`、`yyyy/MM/dd HH:mm`、`MM-dd`、`MM/dd/yyyy`、`dd/MM/yyyy` |
|
||||
|
||||
### `visible_rule` 显隐条件
|
||||
|
||||
> **仅当用户明确要求为题目设置显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
|
||||
|
||||
`visible_rule` 控制题目在表单中的显示/隐藏:当条件满足时题目显示,不满足时隐藏;不传或 `conditions` 为空数组则题目始终显示。
|
||||
|
||||
- **结构与视图筛选 `filter` 完全一致**,即 `{logic?, conditions?}`,共用同一套公共协议。
|
||||
- 与视图 `filter` 唯一的区别:`conditions` 中的 `field` 引用的是**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID 以避免重名歧义),而不是数据表字段。
|
||||
- **只能引用前序题目**:条件只能引用排在当前题目之前的题目——创建时按 `questions` 数组顺序判定(可引用同批次更靠前的新题目或表单中已有题目),不支持循环引用。
|
||||
- 引用的题目必须真实存在,否则会报错。
|
||||
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null` 或 `conditions` 为空数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["是否需要发票", "==", "是"],
|
||||
["报销金额", ">=", 1000]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
详细的 `visible_rule` 结构(顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
返回创建成功的问题列表:
|
||||
@@ -115,4 +144,5 @@ lark-cli base +form-questions-create \
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -2,40 +2,60 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
批量更新多维表格表单/问卷中的问题(标题、描述、是否必填)。
|
||||
批量更新多维表格表单/问卷中的问题配置(标题、描述、是否必填、显隐条件等)。
|
||||
|
||||
> [!CAUTION]
|
||||
> `+form-questions-update` 是**题目配置全量覆盖**,不是 patch。对每个传入的题目,未携带的属性会回落为默认值,显式传空字符串 / `null` / 空数组会直接写入空或清空;如果要保留现有属性,必须先用 `+form-questions-list` 查出现状,再把要保留的字段一起带回 `--questions`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 更新一个问题的标题
|
||||
lark-cli base +form-questions-update \
|
||||
# 先读取现有题目配置,作为 read-modify-write 的基线
|
||||
lark-cli base +form-questions-list \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","title":"您的真实姓名是?"}]'
|
||||
--form-id <form_id>
|
||||
|
||||
# 同时更新多个问题
|
||||
# 更新一个问题的标题,同时带回要保留的 required / description / visible_rule 等字段
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[
|
||||
{"id":"q_001","title":"姓名(必填)","required":true},
|
||||
{"id":"q_002","title":"联系方式","required":false}
|
||||
]'
|
||||
--questions '[{"id":"q_001","title":"您的真实姓名是?","description":"请填写真实姓名","required":true,"visible_rule":null}]'
|
||||
|
||||
# 同时更新多个问题;每个对象都应是该题目的目标完整配置
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","title":"姓名(必填)","required":true},{"id":"q_002","title":"联系方式","required":false}]'
|
||||
|
||||
# 更新问题描述(纯文本)
|
||||
# 更新问题描述(纯文本),同时带回要保留的 title / required / visible_rule
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","description":"请填写您的真实姓名"}]'
|
||||
# 更新问题描述(含链接)
|
||||
--questions '[{"id":"q_001","title":"您的姓名","description":"请填写您的真实姓名","required":true,"visible_rule":null}]'
|
||||
# 更新问题描述(含链接),同时带回要保留的 title / required / visible_rule
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","description":"更多说明请参考[帮助文档](https://example.com/help)"}]'
|
||||
--questions '[{"id":"q_001","title":"反馈建议","description":"更多说明请参考[帮助文档](https://example.com/help)","required":false,"visible_rule":null}]'
|
||||
|
||||
# 更新题目显隐条件(visible_rule),同时带回要保留的 title / description / required
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_002","title":"发票抬头","description":"","required":false,"visible_rule":{"logic":"and","conditions":[["q_001","==","是"]]}}]'
|
||||
|
||||
# 清空题目显隐条件(使题目始终显示),同时带回要保留的 title / description / required
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_002","title":"发票抬头","description":"","required":false,"visible_rule":null}]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -52,15 +72,46 @@ lark-cli base +form-questions-update \
|
||||
|
||||
## `--questions` 格式
|
||||
|
||||
每个问题对象必须包含 `id`,其余字段按需传入:
|
||||
每个问题对象必须包含 `id`。注意:对象不是增量 patch,而是该题目的目标完整配置;未携带字段会按服务端默认值重建。
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | **是** | 问题 ID(field_id),不可修改 |
|
||||
| `title` | 否 | 新的问题标题 |
|
||||
| `description` | 否 | 新的问题描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)`) |
|
||||
| `required` | 否 | 是否必填 |
|
||||
| `option_display_mode` | 否 | 选项展示方式(仅 `select` 有效):`0`=下拉,`1`=纵向(默认),`2`=横向 |
|
||||
| `title` | 否 | 目标问题标题;省略会回落为字段名,传空字符串会写入空标题(若服务端允许) |
|
||||
| `description` | 否 | 目标问题描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)`);省略或传空字符串都会清空描述 |
|
||||
| `required` | 否 | 目标是否必填;省略会回落为 `false` |
|
||||
| `option_display_mode` | 否 | 目标选项展示方式(仅 `select` 有效):`0`=下拉,`1`=纵向(默认),`2`=横向;省略会回落默认展示方式 |
|
||||
| `visible_rule` | 否 | 目标题目显隐条件;传完整 `{logic, conditions}` 对象覆盖,传 `null` 或省略都会清空(见下方说明) |
|
||||
|
||||
## 全量覆盖语义
|
||||
|
||||
- 先执行 `+form-questions-list`,读取被更新题目的当前 `id`、`title`、`description`、`required`、`option_display_mode`、`visible_rule`。
|
||||
- 构造 `--questions` 时,只改用户明确要求变化的字段;所有仍要保留的字段必须按当前值一并传回。
|
||||
- 不要用“只传要改的字段”的方式更新题目。比如只传 `{"id":"q_002","title":"新标题"}` 会让 `description` 清空、`required` 回落为 `false`、`visible_rule` 清空。
|
||||
- 用户明确要求清空时才传空值:`description:""` 清空描述,`visible_rule:null` 清空显隐条件,`conditions:[]` 也表示无条件显示。
|
||||
|
||||
### `visible_rule` 显隐条件
|
||||
|
||||
> **仅当用户明确要求为题目设置或修改显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
|
||||
|
||||
`visible_rule` 控制题目显示/隐藏,**结构与视图筛选 `filter` 完全一致**(`{logic?, conditions?}`),共用同一套公共协议。
|
||||
|
||||
- `conditions` 中的 `field` 引用**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID)。
|
||||
- 更新时按表单中题目的**实际顺序**判定,只能引用排在当前题目之前的题目;不支持循环引用。
|
||||
- 更新 `visible_rule` 需传**完整**的 `{logic, conditions}` 对象(整体覆盖);要保留现有显隐条件就必须把当前 `visible_rule` 原样带回;传 `null`、省略 `visible_rule` 或传空 `conditions` 都会使题目始终显示。
|
||||
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null` 或 `conditions` 为空数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["q_001", "==", "是"],
|
||||
["q_003", ">=", 1000]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
详细的 `visible_rule` 结构(顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
@@ -82,11 +133,13 @@ lark-cli base +form-questions-update \
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** — 执行前必须向用户确认。
|
||||
|
||||
1. 先用 `+form-questions-list` 获取现有问题及其 `id`
|
||||
2. 构造包含 `id` 的更新数组
|
||||
3. 执行命令并报告更新结果
|
||||
1. 先用 `+form-questions-list` 获取现有问题及其 `id` 和完整配置。
|
||||
2. 以现有配置为基线,只修改用户明确要求变化的字段;要保留的字段必须原样带回。
|
||||
3. 构造包含 `id` 和目标完整配置的更新数组。
|
||||
4. 执行命令并报告更新结果。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -4,142 +4,13 @@
|
||||
|
||||
更新视图筛选配置。
|
||||
|
||||
## 1. 顶层规则
|
||||
## 1. filter 结构
|
||||
|
||||
`--json` 就是一个 filter 条件对象,结构见公共协议 SSOT [lark-base-filter-condition.md](lark-base-filter-condition.md),即 `{logic?, conditions?}`。此处 `conditions` 中的 `field` 引用**数据表字段名或字段 id**。
|
||||
|
||||
- `--json` 必须是 JSON 对象。
|
||||
- 顶层结构是 `{logic?, conditions?}`。
|
||||
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`。
|
||||
- `conditions` 默认空数组。
|
||||
- 每条条件写成 tuple:`[field, operator, value?]`。
|
||||
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]`、`[field, "non_empty"]`。
|
||||
- 支持 `filter` 的视图类型:`grid`、`kanban`、`gallery`、`calendar`、`gantt`。
|
||||
|
||||
## 2. operator
|
||||
|
||||
可用 operator:
|
||||
- `==`
|
||||
- `!=`
|
||||
- `>`
|
||||
- `>=`
|
||||
- `<`
|
||||
- `<=`
|
||||
- `intersects`
|
||||
- `disjoint`
|
||||
- `empty`
|
||||
- `non_empty`
|
||||
|
||||
## 3. value 写法
|
||||
|
||||
### `text`
|
||||
|
||||
用字符串:
|
||||
|
||||
```json
|
||||
["标题", "intersects", "发布"]
|
||||
```
|
||||
|
||||
### `location`
|
||||
|
||||
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
|
||||
|
||||
```json
|
||||
["位置", "intersects", "深圳"]
|
||||
```
|
||||
|
||||
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
|
||||
|
||||
### `number` / `auto_number`
|
||||
|
||||
用数字:
|
||||
|
||||
```json
|
||||
["工时", ">=", 3.5]
|
||||
```
|
||||
|
||||
### `select`
|
||||
|
||||
用选项名数组:
|
||||
|
||||
```json
|
||||
["状态", "intersects", ["Doing", "Blocked"]]
|
||||
```
|
||||
|
||||
### `user` / `created_by` / `updated_by`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id:`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
|
||||
|
||||
```json
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]]
|
||||
```
|
||||
|
||||
### `group_chat`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
|
||||
|
||||
```json
|
||||
["负责群", "intersects", [{ "id": "oc_xxx" }]]
|
||||
```
|
||||
|
||||
### `link`
|
||||
|
||||
用记录 id 对象数组:
|
||||
|
||||
```json
|
||||
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
|
||||
```
|
||||
|
||||
### `checkbox`
|
||||
|
||||
用布尔值:
|
||||
|
||||
```json
|
||||
["完成", "==", true]
|
||||
```
|
||||
|
||||
### `datetime` / `created_at` / `updated_at`
|
||||
|
||||
用相对时间关键字或 `ExactDate(...)`:
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "Today"]
|
||||
```
|
||||
|
||||
可用关键字:
|
||||
- `Today`
|
||||
- `Yesterday`
|
||||
- `Tomorrow`
|
||||
|
||||
### `formula` / `lookup`
|
||||
|
||||
- 筛选值类型由字段计算结果类型动态决定。
|
||||
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
|
||||
- 如果报错,再按错误提示把 `value` 改成对应类型。
|
||||
|
||||
字符串示例:
|
||||
|
||||
```json
|
||||
["风险说明", "intersects", "高风险"]
|
||||
```
|
||||
|
||||
数字示例:
|
||||
|
||||
```json
|
||||
["汇总分", ">=", 80]
|
||||
```
|
||||
|
||||
## 4. 推荐命令
|
||||
## 2. 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +view-set-filter \
|
||||
@@ -149,7 +20,7 @@ lark-cli base +view-set-filter \
|
||||
--json '{"logic":"and","conditions":[["状态","intersects",["Doing"]],["负责人","intersects",[{"id":"ou_xxx"}]],["截止时间","empty"]]}'
|
||||
```
|
||||
|
||||
## 5. JSON 写法
|
||||
## 3. JSON 写法
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -170,14 +41,16 @@ lark-cli base +view-set-filter \
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 使用建议
|
||||
完整的 operator 列表与各字段类型的 value 写法(`text` / `number` / `select` / `user` / `datetime` / `formula` / `lookup` 等),见 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 4. 使用建议
|
||||
|
||||
- 先读取当前筛选配置,理解现有 `logic` 和 `conditions` 的组合关系;只替换用户要求变更的条件,未提到的条件默认保留。
|
||||
- 优先传字段 id,不要依赖字段名。
|
||||
- 拿不准字段 type 或真实取值时,先用 `+field-list` / `+record-list` 确认,再按对应字段类型的 value 写法构造条件;别按字段名猜 type、凭印象猜枚举取值。
|
||||
- 需要清空全部筛选时,直接传 `{"conditions":[]}`。
|
||||
|
||||
## 7. 易错点
|
||||
## 5. 易错点
|
||||
|
||||
- 本 tuple DSL 由 `+view-set-filter` 与 `+record-list` / `+record-search` 的 `--filter-json` 共用;不要写成 `+data-query` 的对象风格 `{"field_name":...,"operator":...}`(会报校验失败)。
|
||||
- 标量类字段(`text` / `number` / `datetime` 等)的 value 用标量、别包成数组(各类型详见 value 写法一节)。
|
||||
@@ -186,6 +59,7 @@ lark-cli base +view-set-filter \
|
||||
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
||||
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前 filter 或字段定义,或根据错误提示修正类型。
|
||||
|
||||
## 8. 参考
|
||||
## 6. 参考
|
||||
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md):filter/visible_rule 条件结构公共协议 SSOT
|
||||
- [lookup-field-guide.md](lookup-field-guide.md)
|
||||
|
||||
@@ -16,14 +16,16 @@ metadata:
|
||||
|
||||
## 身份
|
||||
|
||||
日程操作默认使用 `--as user`(查看和管理当前用户的日程)。`--as bot` 只能访问 bot 自己的(空)日历,会拿到空结果——不要用 bot 身份查用户日程。
|
||||
按**日程归属**选身份:
|
||||
|
||||
- 查看/管理登录用户本人的日程 → `--as user`(默认,绝大多数场景)。
|
||||
- 查看/管理 bot 自己创建/拥有的日程 → `--as bot`
|
||||
|
||||
```bash
|
||||
# BAD — bot 身份查用户日程,返回空列表
|
||||
lark-cli calendar +agenda --as bot
|
||||
|
||||
# GOOD — user 身份查日程
|
||||
# 用户本人日程 → user
|
||||
lark-cli calendar +agenda --as user
|
||||
# bot 自建或参与的日程 → bot
|
||||
lark-cli calendar +agenda --as bot
|
||||
```
|
||||
|
||||
## Shortcuts
|
||||
@@ -48,7 +50,7 @@ lark-cli calendar +agenda --as user
|
||||
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
|
||||
```
|
||||
|
||||
读取日程时同时返回 `description`(纯文本)和 `description_rich`(**Markdown** 富文本)两个字段:`description` 存纯文本,`description_rich` 存富文本;仅有纯文本描述时,`description_rich` 会用该纯文本兜底填充(两字段值相同)。创建/更新日程时只传 `description_rich`(Markdown)。
|
||||
日程描述统一使用 `description` 一个字段,按 **Markdown** 富文本处理。读取日程时 `description` 返回 Markdown 富文本(仅有纯文本描述时返回该纯文本);创建/更新日程时也通过 `--description` 传入 Markdown。
|
||||
|
||||
### `+search-event` — 按关键词、时间范围和参会人搜索日程
|
||||
|
||||
@@ -188,6 +190,8 @@ lark-cli contact +search-user --query <query> --as user
|
||||
lark-cli im +chat-search --query <query> --as user
|
||||
```
|
||||
|
||||
> 搜索用户接口不支持 bot 身份,必须用 `--as user`;搜到的 `ou_` open_id 用于日程参与人操作(如添加日程参与人)。
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||
|
||||
@@ -32,20 +32,19 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
| `--summary <text>` | 否 | 日程标题。注意:标题中不应该出现时间、地点、人物信息 |
|
||||
| `--start <time>` | 是 | 开始时间(ISO 8601,如 `2026-03-12T14:00+08:00`) |
|
||||
| `--end <time>` | 是 | 结束时间(ISO 8601) |
|
||||
| `--description-rich <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ` 到 `### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 ``(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL(直接粘贴裸链接,或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径` 或 `-`(stdin)读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。|
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
||||
| `--description <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ` 到 `### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 ``(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL(直接粘贴裸链接,或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径` 或 `-`(stdin)读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。|
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀。bot 可作为合法参会人,无需剔除 |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。示例值:"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
|
||||
> `--description-rich` 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
|
||||
> `--description` 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
|
||||
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
|
||||
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
|
||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
||||
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
||||
> 搜索用户接口不支持 bot 身份,需用 `--as user` 进行搜索。
|
||||
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
|
||||
|
||||
## 高级用法(完整 API 命令)
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
|
||||
| 步骤 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description-rich ...` | 更新原重复性日程的标题/描述等 |
|
||||
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description-rich ...` (逐个) | 同步更新例外日程的对应字段 |
|
||||
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description ...` | 更新原重复性日程的标题/描述等 |
|
||||
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description ...` (逐个) | 同步更新例外日程的对应字段 |
|
||||
|
||||
> 理由:例外已脱离原重复性日程独立存在,不会自动继承原日程的更新。
|
||||
|
||||
|
||||
@@ -50,12 +50,13 @@ lark-cli calendar +room-find \
|
||||
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名、会议室号或编号区间时使用。 |
|
||||
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时,提取数字放入此参数,必须为正整数。 |
|
||||
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。**不要传入 bot 的 open_id**:bot 是虚拟身份,不占会议室席位、无会议室偏好,传入只会干扰推荐结果。 |
|
||||
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
|
||||
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai`) |
|
||||
|
||||
## 规则
|
||||
|
||||
- 构造 `--attendee-ids` 前,先剔除 bot 参会人:bot 不占席位、无偏好,不应参与会议室推荐。
|
||||
- 多个 `--slot` 会由 CLI 内部并发调用单时间块接口,再聚合成一次输出
|
||||
- `+room-find` 的时间输入必须是**确定时间块**,不是时间区间搜索。
|
||||
- 如果是重复性日程,必须校验返回中的 `reserve_until_time`(该会议室最晚可预约时间)是否覆盖 `event-rrule` 对应的重复范围。
|
||||
|
||||
@@ -39,6 +39,7 @@ 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 使用英文逗号分隔 |
|
||||
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户(`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔。**不要传入 bot 的 open_id**:bot 是虚拟身份,可并行多个会议、无忙闲语义,传入会干扰推荐时段的忙闲计算。 |
|
||||
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
|
||||
| `--duration-minutes <min>` | 否 | 会议时长(分钟)。优先使用用户显式指定的值,若未指定则尝试根据上下文推断,推断失败则不传 |
|
||||
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai`) |
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
lark-cli calendar +update \
|
||||
--event-id "<EVENT_ID>" \
|
||||
--summary "产品评审" \
|
||||
--description-rich "评审需求范围、排期与风险" \
|
||||
--description "评审需求范围、排期与风险" \
|
||||
--start "2026-03-12T14:00+08:00" \
|
||||
--end "2026-03-12T15:00+08:00"
|
||||
|
||||
@@ -43,7 +43,7 @@ lark-cli calendar +update \
|
||||
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID,详见 [重复性日程操作规范](lark-calendar-recurring.md) |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用 `primary`) |
|
||||
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
|
||||
| `--description-rich <markdown>` | 否 | 新日程描述,统一使用此字段,格式为 **Markdown**(加粗、斜体、下划线 `<u>...</u>`、删除线、链接 `[文本](url)`、标题 `# `~`### `(最多三级)、引用 `> `、有序/无序列表、GFM 表格 `\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`、以及图片 ``(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL(裸链接或 `[文本](url)`)会自动解析为内联文档,端上展示文档标题。支持 `@文件路径` 或 `-`(stdin)读取。仅在显式传入时更新;传空字符串 `""` 会清空描述。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。 |
|
||||
| `--description <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,17 +52,18 @@ lark-cli calendar +update \
|
||||
| `--notify` | 否 | 是否发送更新通知,默认 `true`。可用 `--notify=false` 静默更新 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
至少需要提供一个动作:`--summary`、`--description-rich`、`--start/--end`、`--rrule`、`--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
至少需要提供一个动作:`--summary`、`--description`、`--start/--end`、`--rrule`、`--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
|
||||
## 使用规则
|
||||
|
||||
- `--add-attendee-ids` 是**增量添加**,不是替换最终参与人列表。不要用它表达“只保留这些人”。
|
||||
- 对 `--summary`、`--description-rich`,CLI 以“是否显式传入该 flag”判断是否更新,而不是以“值是否为空”判断;如果显式传入空字符串,会把对应字段清空。
|
||||
- 日程描述统一走 `--description-rich`(Markdown)。`--description`(纯文本)已废弃并从帮助中隐藏。
|
||||
- 对 `--summary`、`--description`,CLI 以“是否显式传入该 flag”判断是否更新,而不是以“值是否为空”判断;如果显式传入空字符串,会把对应字段清空。
|
||||
- 日程描述统一走 `--description`(按 Markdown 富文本处理)。
|
||||
- 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
|
||||
- 只想增删参会人或会议室时,不需要同时传 `--summary`、`--start`、`--end` 等日程字段。
|
||||
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
||||
- bot 可作为合法参会人添加,无需剔除。
|
||||
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
|
||||
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
|
||||
@@ -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,6 +27,7 @@ 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`;只移动一个已明确资源时仍使用原子移动命令。
|
||||
@@ -120,6 +121,7 @@ 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。
|
||||
|
||||
@@ -163,6 +165,8 @@ 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 文件或文档的密级标签。 |
|
||||
|
||||
|
||||
65
skills/lark-drive/references/lark-drive-member-list.md
Normal file
65
skills/lark-drive/references/lark-drive-member-list.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 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`。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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` 不会递归读取子资源。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user