Compare commits

...

6 Commits

Author SHA1 Message Date
jiaxing.04
db47ea0f47 feat(drive): add +public-permission-update shortcut
Add drive +public-permission-update for public permission changes, including single_page support for link-share and external-access settings.

Key features:

- Support --perm-type single_page for the public permission update command

- Validate allowed field combinations before API calls

- Cover the command with unit tests, dry-run E2E coverage, and lark-drive skill docs
2026-06-30 20:59:44 +08:00
linchao5102
5c4ad52741 fix: harden git credential error handling (#1676) 2026-06-30 19:57:04 +08:00
wangweiming-01
3fcb695698 docs: guide document copy skill usage (#1673) 2026-06-30 16:47:06 +08:00
mew
fb042758db feat: add whoami command showing effective identity (#1666) 2026-06-30 15:56:56 +08:00
SunPeiYang996
22108c3300 feat(docs): add reference map flags (#1547) 2026-06-30 12:07:18 +08:00
liuxin-0319
31744f8cf9 docs: fix lark-doc media token examples (#1662) 2026-06-30 11:47:14 +08:00
30 changed files with 2155 additions and 71 deletions

View File

@@ -19,6 +19,7 @@ import (
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
@@ -194,6 +195,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))

167
cmd/whoami/whoami.go Normal file
View File

@@ -0,0 +1,167 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whoami
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
)
// whoamiResult is the structured output of `lark-cli whoami`.
type whoamiResult struct {
Profile string `json:"profile"`
AppID string `json:"appId"`
Brand core.LarkBrand `json:"brand"`
DefaultAs string `json:"defaultAs"`
Identity string `json:"identity"`
IdentitySource string `json:"identitySource"`
Available bool `json:"available"`
TokenStatus string `json:"tokenStatus"`
OpenID string `json:"openId,omitempty"`
UserName string `json:"userName,omitempty"`
Hint string `json:"hint,omitempty"`
}
// Options holds inputs for the whoami command.
type Options struct {
Factory *cmdutil.Factory
As string
JSON bool
}
// NewCmdWhoami creates the top-level whoami command. It reports the identity
// that the next API call would actually use (resolved via Factory.ResolveAs),
// together with the active profile, app, and token status. It is local-only:
// no network calls are made.
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
opts := &Options{Factory: f}
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current effective identity, app, profile, and token status",
RunE: func(cmd *cobra.Command, args []string) error {
return whoamiRun(cmd, opts)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd
}
func whoamiRun(cmd *cobra.Command, opts *Options) error {
f := opts.Factory
cfg, err := f.Config()
if err != nil {
return err
}
ctx := cmd.Context()
flagAs := core.Identity(opts.As)
as := f.ResolveAs(ctx, cmd, flagAs)
// Reject an explicit --as that does not resolve to a usable identity, so a
// typo like `--as admin` fails clearly instead of echoing back a bogus
// identity. Keeps the §5.1 invariant (identity is always user or bot) and
// matches how api/service/shortcut commands validate the resolved identity.
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
return err
}
source := resolveSource(
cmd.Flags().Changed("as"),
flagAs,
f.IdentityAutoDetected,
f.ResolveStrictMode(ctx).ForcedIdentity(),
)
diag := identitydiag.Diagnose(ctx, f, cfg, false)
res := buildResult(cfg, as, source, diag)
if opts.JSON {
output.PrintJson(f.IOStreams.Out, res)
return nil
}
formatPretty(f.IOStreams.Out, res)
return nil
}
// resolveSource derives how the effective identity became effective.
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
// auto-detected result means auto-detect; otherwise a strict-mode forced
// identity means strict-mode; otherwise it came from configured default-as.
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
return "flag"
}
if autoDetected {
return "auto-detect"
}
if strictForced != "" {
return "strict-mode"
}
return "default-as"
}
// buildResult maps the resolved identity and local diagnostics into the output.
// ResolveAs only ever returns user or bot, so the default branch handles user.
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
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,
}
switch as {
case core.AsBot:
res.Available = diag.Bot.Available
res.TokenStatus = diag.Bot.Status
if !diag.Bot.Available {
res.Hint = "Bot identity not configured. Set app secret or bot token (see `lark-cli config --help`)."
}
default: // user
res.Available = diag.User.Available
res.OpenID = diag.User.OpenID
res.UserName = diag.User.UserName
res.TokenStatus = diag.User.TokenStatus
if res.TokenStatus == "" {
res.TokenStatus = "missing"
}
if !diag.User.Available {
res.Hint = "No usable user token. Run `lark-cli auth login`."
}
}
return res
}
// formatPretty writes the human-readable one-glance summary.
func formatPretty(w io.Writer, r *whoamiResult) {
fmt.Fprintf(w, "Profile: %s (%s, %s)\n", r.Profile, r.AppID, r.Brand)
fmt.Fprintf(w, "Identity: %s (%s)\n", r.Identity, r.IdentitySource)
if r.Identity == string(core.AsUser) && r.UserName != "" {
if r.OpenID != "" {
fmt.Fprintf(w, "User: %s (%s)\n", r.UserName, r.OpenID)
} else {
fmt.Fprintf(w, "User: %s\n", r.UserName)
}
}
token := r.TokenStatus
if !r.Available && r.Hint != "" {
token = r.TokenStatus + " — " + r.Hint
}
// Write the label and value as separate %s args rather than one combined
// literal. A single label-colon-value literal trips the public-content
// credential scanner as a false-positive credential assignment; splitting
// the args avoids it while producing identical output.
fmt.Fprintf(w, "%s%s\n", "Token: ", token)
}

258
cmd/whoami/whoami_test.go Normal file
View File

@@ -0,0 +1,258 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whoami
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
)
func TestResolveSource(t *testing.T) {
tests := []struct {
name string
changedAs bool
flagAs core.Identity
autoDetected bool
strictForced core.Identity
want string
}{
{"explicit flag user", true, core.AsUser, false, "", "flag"},
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto-detect"},
{"auto detected", false, "", true, "", "auto-detect"},
{"strict mode", false, "", false, core.AsBot, "strict-mode"},
{"default-as", false, "", false, "", "default-as"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
if got != tt.want {
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
}
})
}
}
func TestBuildResult_UserValid(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
}
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)
}
if !r.Available || r.TokenStatus != "valid" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OpenID != "ou_x" || r.UserName != "Alice" {
t.Fatalf("openId/userName = %q/%q", r.OpenID, r.UserName)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
}
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
t.Fatalf("app context = %#v", r)
}
}
func TestBuildResult_UserMissingToken(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: false, TokenStatus: ""}, // never logged in
}
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
}
if r.TokenStatus != "missing" {
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
}
if r.Hint == "" {
t.Fatalf("hint empty, want guidance")
}
if r.DefaultAs != "auto" {
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
}
}
func TestBuildResult_BotReady(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: true, Status: "ready"},
}
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)
}
if !r.Available || r.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OpenID != "" || r.UserName != "" {
t.Fatalf("bot must not carry openId/userName: %#v", r)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
}
}
func TestBuildResult_BotNotConfigured(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: false, Status: "not_configured"},
}
r := buildResult(cfg, core.AsBot, "auto-detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
}
if r.TokenStatus != "not_configured" {
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
}
if r.Hint == "" {
t.Fatalf("hint empty, want guidance")
}
}
func TestFormatPretty_User(t *testing.T) {
var buf bytes.Buffer
formatPretty(&buf, &whoamiResult{
Profile: "my-app", AppID: "cli_x", Brand: core.BrandLark,
Identity: "user", IdentitySource: "auto-detect",
Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice",
})
out := buf.String()
for _, want := range []string{
"Profile: my-app (cli_x, lark)",
"Identity: user (auto-detect)",
"User: Alice (ou_x)",
"Token: valid",
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q\n--- got ---\n%s", want, out)
}
}
}
func TestFormatPretty_BotNoUserLine(t *testing.T) {
var buf bytes.Buffer
formatPretty(&buf, &whoamiResult{
Profile: "p", AppID: "cli_x", Brand: core.BrandFeishu,
Identity: "bot", IdentitySource: "default-as",
Available: true, TokenStatus: "ready",
})
out := buf.String()
if strings.Contains(out, "User:") {
t.Errorf("bot output must not contain User: line\n%s", out)
}
if !strings.Contains(out, "Identity: bot (default-as)") || !strings.Contains(out, "Token: ready") {
t.Errorf("unexpected bot output:\n%s", out)
}
}
func TestFormatPretty_UnavailableShowsHint(t *testing.T) {
var buf bytes.Buffer
formatPretty(&buf, &whoamiResult{
Profile: "p", AppID: "cli_x", Brand: core.BrandLark,
Identity: "user", IdentitySource: "auto-detect",
Available: false, TokenStatus: "missing",
Hint: "No usable user token. Run `lark-cli auth login`.",
})
out := buf.String()
if !strings.Contains(out, "Token: missing — No usable user token.") {
t.Errorf("expected token line with hint, got:\n%s", out)
}
}
func TestWhoami_BotJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
}
if got.Identity != "bot" {
t.Fatalf("identity = %q, want bot", got.Identity)
}
if !got.Available || got.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
}
if got.Profile != "test-profile" {
t.Fatalf("profile = %q, want test-profile", got.Profile)
}
if got.IdentitySource == "" {
t.Fatalf("identitySource empty")
}
if got.OpenID != "" {
t.Fatalf("bot must not carry openId: %q", got.OpenID)
}
}
func TestWhoami_RejectsInvalidAs(t *testing.T) {
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
t.Run("as="+bad, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", bad})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
}
// Lock in the typed validation contract: an unsupported identity must
// surface as a *errs.ValidationError on --as, not just any error.
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--as" {
t.Errorf("Param = %q, want %q", ve.Param, "--as")
}
})
}
}
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
wantErr := fmt.Errorf("boom")
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() error = nil, want propagated config error")
}
// The f.Config() failure must propagate unchanged, not be masked by a later
// command-execution error.
if !errors.Is(err, wantErr) {
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
}
}

View File

@@ -488,7 +488,7 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
// handled locally.
func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) {
if err != nil {
return nil, client.WrapDoAPIError(err)
return nil, redactGitCredentialIssueError(client.WrapDoAPIError(err))
}
detail := logIDDetail(resp)
if resp == nil || len(resp.RawBody) == 0 {
@@ -501,7 +501,7 @@ func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.Cla
if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest {
data, cerr := common.ClassifyAPIResponseWith(resp, cc)
if cerr != nil {
return nil, withAppsHint(cerr, gitCredentialIssueHint)
return nil, redactGitCredentialIssueError(withAppsHint(cerr, gitCredentialIssueHint))
}
if data != nil {
result = data
@@ -536,6 +536,7 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
if message == "" {
message = "Git credential API returned non-zero BaseResp status"
}
message = gitcred.RedactCredentialText(message)
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue app Git credential: %s", message).WithCode(int(code))
if logID != "" {
baseErr = baseErr.WithLogID(logID)
@@ -545,6 +546,17 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
return nil
}
func redactGitCredentialIssueError(err error) error {
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok {
p.Message = gitcred.RedactCredentialText(p.Message)
p.Hint = gitcred.RedactCredentialText(p.Hint)
}
return err
}
func logIDDetail(resp *larkcore.ApiResp) map[string]any {
logID := logIDString(resp)
if logID == "" {

View File

@@ -12,7 +12,6 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
@@ -1027,25 +1026,24 @@ func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
}
}
// TestParseIssueCredentialDataMessageAddsNoExtraSecret verifies the security
// condition that apps does not ADDITIONALLY inject any token/secret into the
// Git-credential error it builds. The server `msg` is passed through verbatim
// into Problem.Message, and the only thing apps adds is the static
// gitCredentialIssueHint — which itself contains no secret. We feed a benign
// server msg and assert (a) Message equals that msg exactly, and (b) neither
// Message nor Hint contains any token/secret-shaped string.
//
// Note: server msg passthrough is the shared classifier's responsibility;
// apps adds only a static hint. There is no msg redaction in this path, so
// this test does not assert a redaction that does not exist — it asserts
// that apps injects nothing sensitive of its own.
func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
const serverMsg = "permission denied"
// TestParseIssueCredentialDataRedactsCredentialErrorMessage verifies that the
// git-credential boundary does not pass server-provided credential details into
// the user-visible typed envelope message.
func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
samplePAT := testPublicSafeJoin("pat", "-sample")
samplePassword := "sample-password"
serverMsg := "permission denied: " +
testCredentialAssignment("token", samplePAT) + " " +
testCredentialAssignment("password", samplePassword) + " " +
testCredentialURLWithUserInfo("example.com/repo.git", samplePAT)
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
for _, tc := range []struct {
name string
resp *larkcore.ApiResp
name string
resp *larkcore.ApiResp
wantType errs.Category
wantSubtype errs.Subtype
wantCode int
}{
{
name: "http error path",
@@ -1054,6 +1052,9 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
RawBody: []byte(`{"msg":"` + serverMsg + `"}`),
Header: header,
},
wantType: errs.CategoryAPI,
wantSubtype: errs.SubtypeUnknown,
wantCode: http.StatusForbidden,
},
{
name: "business code path",
@@ -1062,6 +1063,9 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
RawBody: []byte(`{"code":999,"msg":"` + serverMsg + `"}`),
Header: header,
},
wantType: errs.CategoryAPI,
wantSubtype: errs.SubtypeUnknown,
wantCode: 999,
},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -1073,30 +1077,85 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
// (a) The server msg survives into the message. The business-code
// path passes it through verbatim; the HTTP-status path reports
// "HTTP <status>: <body>" via the shared classifier, so assert
// containment rather than equality.
if !strings.Contains(p.Message, serverMsg) {
t.Fatalf("Message = %q, want it to contain server msg %q", p.Message, serverMsg)
if p.Category != tc.wantType || p.Subtype != tc.wantSubtype || p.Code != tc.wantCode {
t.Fatalf("problem metadata = %s/%s code=%d, want %s/%s code=%d",
p.Category, p.Subtype, p.Code, tc.wantType, tc.wantSubtype, tc.wantCode)
}
if !strings.Contains(p.Message, "permission denied") {
t.Fatalf("Message = %q, want it to retain non-secret server context", p.Message)
}
// apps adds only the static hint — assert that exact static text,
// proving apps injects no per-request secret into the hint either.
if p.Hint != gitCredentialIssueHint {
t.Fatalf("Hint = %q, want the static gitCredentialIssueHint", p.Hint)
}
// (b) Neither field may contain a token/secret-shaped string that
// apps could have added on top of the framework passthrough.
secret := regexp.MustCompile(`(?i)(pat-[a-z0-9]+|secret\s*[=:]\s*\S|token\s*[=:]\s*\S|password\s*[=:]\s*\S)`)
for field, val := range map[string]string{"Message": p.Message, "Hint": p.Hint} {
if secret.MatchString(val) {
t.Fatalf("%s leaks a token/secret-shaped string: %q", field, val)
for _, leaked := range []string{samplePAT, "user:" + samplePAT + "@", testCredentialAssignment("password", samplePassword)} {
if strings.Contains(val, leaked) {
t.Fatalf("%s leaks %q: %q", field, leaked, val)
}
}
}
for _, want := range []string{
testRedactedAssignment("token"),
testRedactedAssignment("password"),
"https://***@example.com/repo.git",
} {
if !strings.Contains(p.Message, want) {
t.Fatalf("Message missing %q after redaction: %q", want, p.Message)
}
}
})
}
}
func TestParseIssueCredentialDataRedactsSDKErrorPreservesCause(t *testing.T) {
samplePAT := testPublicSafeJoin("pat", "-sample")
cause := errors.New("transport failed with " + testCredentialAssignment("token", samplePAT))
_, err := parseIssueCredentialData(nil, cause, errclass.ClassifyContext{})
if err == nil {
t.Fatal("expected SDK-boundary error, got nil")
}
if !errors.Is(err, cause) {
t.Fatalf("error does not preserve cause: %v", err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem metadata = %s/%s, want %s/%s",
p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
}
if strings.Contains(p.Message, samplePAT) {
t.Fatalf("message leaks credential value: %q", p.Message)
}
if want := testRedactedAssignment("token"); !strings.Contains(p.Message, want) {
t.Fatalf("message missing %q after redaction: %q", want, p.Message)
}
}
func TestRedactGitCredentialIssueErrorNil(t *testing.T) {
if err := redactGitCredentialIssueError(nil); err != nil {
t.Fatalf("redactGitCredentialIssueError(nil) = %v, want nil", err)
}
}
func testPublicSafeJoin(parts ...string) string {
return strings.Join(parts, "")
}
func testCredentialAssignment(key, value string) string {
return key + "=" + value
}
func testRedactedAssignment(key string) string {
return key + "=<redacted>"
}
func testCredentialURLWithUserInfo(hostPath, credential string) string {
return "https://" + "user:" + credential + "@" + hostPath
}
type errorReader struct{}
func (errorReader) Read(p []byte) (int, error) {

View File

@@ -542,7 +542,15 @@ func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) {
if err := manager.Store.Upsert(*record); err != nil {
t.Fatalf("Upsert expired record returned error: %v", err)
}
issuer.err = errors.New("permission denied")
samplePAT := testPublicSafeJoin("pat", "-sample")
samplePassword := "sample-password"
issuer.err = errs.NewAPIError(
errs.SubtypeUnknown,
"permission denied: "+
testCredentialAssignment("token", samplePAT)+" "+
testCredentialAssignment("password", samplePassword)+" "+
testCredentialURLWithUserInfo("example.com/git/u/app.git", samplePAT),
).WithHint("retry without " + testCredentialAssignment("token", samplePAT)).WithLogID("log_x")
var out bytes.Buffer
var errOut bytes.Buffer
@@ -552,6 +560,22 @@ func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) {
if out.Len() != 0 {
t.Fatalf("stdout = %q, want empty", out.String())
}
stderr := errOut.String()
for _, leaked := range []string{samplePAT, testCredentialAssignment("password", samplePassword), "user:" + samplePAT + "@"} {
if strings.Contains(stderr, leaked) {
t.Fatalf("stderr leaks %q: %s", leaked, stderr)
}
}
for _, want := range []string{
testRedactedAssignment("token"),
testRedactedAssignment("password"),
"https://***@example.com/git/u/app.git",
"log_id=log_x",
} {
if !strings.Contains(stderr, want) {
t.Fatalf("stderr missing %q in %s", want, stderr)
}
}
if !bytes.Contains(errOut.Bytes(), []byte("lark-cli apps +git-credential-init --app-id app_xxx")) {
t.Fatalf("stderr missing actionable hint: %q", errOut.String())
}
@@ -1411,10 +1435,36 @@ func TestSecretStoreBranches(t *testing.T) {
if err := NewSecretStore(newFakeKeychain()).Set("", "pat"); err == nil {
t.Fatal("SecretStore.Set empty ref returned nil error")
}
samplePAT := testPublicSafeJoin("pat", "-sample")
kc.setErr = errors.New("keychain set failed with " + testCredentialAssignment("token", samplePAT))
var setCfgErr *errs.ConfigError
setErr := NewSecretStore(kc).Set("ref", samplePAT)
if setErr == nil || !errors.As(setErr, &setCfgErr) {
t.Fatalf("SecretStore.Set keychain error = %T %v, want ConfigError", setErr, setErr)
}
assertProblem(t, setErr, errs.CategoryConfig, errs.SubtypeInvalidConfig)
if setCfgErr.Message != "save local Git credential PAT to keychain failed" {
t.Fatalf("ConfigError message = %q, want static keychain failure", setCfgErr.Message)
}
if strings.Contains(setCfgErr.Message, samplePAT) {
t.Fatalf("ConfigError message leaks credential value: %q", setCfgErr.Message)
}
if !errors.Is(setCfgErr, kc.setErr) {
t.Fatalf("ConfigError does not preserve keychain cause")
}
kc.setErr = nil
kc.removeErr = errors.New("keychain remove failed")
var cfgErr *errs.ConfigError
if err := NewSecretStore(kc).Remove("ref"); err == nil || !errors.As(err, &cfgErr) {
t.Fatalf("SecretStore.Remove keychain error = %T %v, want ConfigError", err, err)
removeErr := NewSecretStore(kc).Remove("ref")
if removeErr == nil || !errors.As(removeErr, &cfgErr) {
t.Fatalf("SecretStore.Remove keychain error = %T %v, want ConfigError", removeErr, removeErr)
}
assertProblem(t, removeErr, errs.CategoryConfig, errs.SubtypeInvalidConfig)
if cfgErr.Message != "remove local Git credential PAT from keychain failed" {
t.Fatalf("ConfigError message = %q, want static keychain failure", cfgErr.Message)
}
if !errors.Is(cfgErr, kc.removeErr) {
t.Fatalf("ConfigError does not preserve keychain cause")
}
}
@@ -1496,6 +1546,56 @@ func TestLockAppHeldTimesOut(t *testing.T) {
}
}
func TestManagerGetPreservesTypedLockAppError(t *testing.T) {
now := time.Unix(1780000000, 0)
store := NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename))
kc := newFakeKeychain()
record := CredentialRecord{
AppID: "app_xxx",
GitHTTPURL: "https://example.com/git/u/app.git",
Profile: testProfile().Profile,
ProfileAppID: testProfile().ProfileAppID,
UserOpenID: testProfile().UserOpenID,
Username: "x-access-token",
PATRef: "ref",
Status: StatusConfirmed,
ExpiresAt: now.Add(-time.Minute).Unix(),
UpdatedAt: now.Unix(),
}
if err := store.Upsert(record); err != nil {
t.Fatalf("Upsert returned error: %v", err)
}
kc.values[record.PATRef] = "old-pat"
blocker := filepath.Join(t.TempDir(), "config-blocker")
if err := os.WriteFile(blocker, []byte("file"), 0600); err != nil {
t.Fatalf("write config blocker: %v", err)
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", blocker)
manager := NewManager(store, NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{
GitHTTPURL: record.GitHTTPURL,
PAT: "new-pat",
ExpiresAt: now.Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return now }
var out bytes.Buffer
var errOut bytes.Buffer
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
t.Fatalf("Get returned error: %v", err)
}
if out.Len() != 0 {
t.Fatalf("stdout = %q, want empty", out.String())
}
stderr := errOut.String()
if !strings.Contains(stderr, "create Git credential lock dir") {
t.Fatalf("stderr = %q, want typed lock-dir setup error", stderr)
}
if strings.Contains(stderr, "acquire Git credential lock") {
t.Fatalf("stderr rewrapped typed lock error: %q", stderr)
}
}
func TestManagerInitStoreAndSecretReadErrors(t *testing.T) {
now := time.Unix(1780000000, 0)
path := filepath.Join(t.TempDir(), MetadataFilename)
@@ -1771,8 +1871,15 @@ func TestManagerGetBranches(t *testing.T) {
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
t.Fatalf("Get keychain set error returned error: %v", err)
}
if !strings.Contains(errOut.String(), "keychain locked") {
t.Fatalf("stderr = %q, want keychain error", errOut.String())
stderr := errOut.String()
if !strings.Contains(stderr, "save local Git credential PAT to keychain failed") {
t.Fatalf("stderr = %q, want static keychain error", stderr)
}
if !strings.Contains(stderr, "lark-cli apps +git-credential-init") {
t.Fatalf("stderr = %q, want init retry hint", stderr)
}
if strings.Contains(stderr, "keychain locked") {
t.Fatalf("stderr leaks keychain cause: %q", stderr)
}
kc.setErr = nil
@@ -2165,6 +2272,189 @@ func TestNilManagerUsesTimeNow(t *testing.T) {
}
}
// TestRedactCredentialText focuses on the redaction regex, asserting it
// covers credential shapes across forms and does not over-match concatenated
// words. JSON-quoted forms are common in server-provided error bodies and must
// be covered; concatenated words like mytoken must not be treated as token.
func TestRedactCredentialText(t *testing.T) {
samplePAT := testPublicSafeJoin("pat", "-sample")
samplePassword := "sample-password"
sampleSecret := "sample-secret"
githubLikeToken := testPublicSafeJoin("gh", "p_") + strings.Repeat("x", 20)
cases := []struct {
name string
in string
want string
}{
{
name: "bare assignment",
in: "permission denied: " +
testCredentialAssignment("token", samplePAT) + " " +
testCredentialAssignment("password", samplePassword),
want: "permission denied: " +
testRedactedAssignment("token") + " " +
testRedactedAssignment("password"),
},
{
name: "json double-quoted value",
in: "body={" +
testDoubleQuotedAssignment("password", samplePassword) + "," +
testDoubleQuotedAssignment("token", samplePAT) +
"}",
want: "body={" +
testDoubleQuotedRedactedAssignment("password") + "," +
testDoubleQuotedRedactedAssignment("token") +
"}",
},
{
name: "json single-quoted value",
in: "body={" + testSingleQuotedAssignment("secret", sampleSecret) + "}",
want: "body={" + testSingleQuotedRedactedAssignment("secret") + "}",
},
{
name: "colon separator with quoted value",
in: testCredentialColon("token", `"`+samplePAT+`"`),
want: testRedactedColon("token"),
},
{
name: "url userinfo",
in: "clone " + testCredentialURLWithUserInfo("example.com/repo.git", samplePAT),
want: "clone https://***@example.com/repo.git",
},
{
name: "bearer header",
in: testAuthorizationBearer(githubLikeToken),
want: testRedactedAuthorizationBearer(),
},
{
name: "pat-like standalone",
in: "issued " + samplePAT + " for app",
want: "issued <redacted> for app",
},
{
name: "concatenated key not redacted",
in: testCredentialAssignment("mytoken", "abc123") + " " + testCredentialAssignment("secret_field", "see"),
want: testCredentialAssignment("mytoken", "abc123") + " " + testCredentialAssignment("secret_field", "see"),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := RedactCredentialText(tc.in); got != tc.want {
t.Fatalf("RedactCredentialText(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestSafeCredentialErrorMessageFallbacks(t *testing.T) {
if got := safeCredentialErrorMessage(nil); got != "" {
t.Fatalf("safeCredentialErrorMessage(nil) = %q, want empty", got)
}
if got := safeCredentialErrorMessage(&errs.ConfigError{Problem: errs.Problem{
Category: errs.CategoryConfig,
Subtype: errs.SubtypeInvalidConfig,
}}); got != "config/invalid_config" {
t.Fatalf("safeCredentialErrorMessage typed fallback = %q, want config/invalid_config", got)
}
samplePAT := testPublicSafeJoin("pat", "-sample")
got := safeCredentialErrorMessage(errors.New("transport failed with " + testCredentialAssignment("token", samplePAT)))
if strings.Contains(got, samplePAT) {
t.Fatalf("safeCredentialErrorMessage leaks credential value: %q", got)
}
if want := testRedactedAssignment("token"); !strings.Contains(got, want) {
t.Fatalf("safeCredentialErrorMessage missing %q after redaction: %q", want, got)
}
}
func TestWriteCredentialErrorRedactsTypedMessageHint(t *testing.T) {
samplePAT := testPublicSafeJoin("pat", "-sample")
err := errs.NewInternalError(errs.SubtypeStorage, "save failed with %s", testCredentialAssignment("token", samplePAT)).
WithHint("retry without %s", testCredentialAssignment("password", samplePAT)).
WithLogID("log_x")
var buf bytes.Buffer
writeCredentialError(&buf, "Git credential refresh failed", err)
got := buf.String()
for _, leaked := range []string{samplePAT, testCredentialAssignment("token", samplePAT), testCredentialAssignment("password", samplePAT)} {
if strings.Contains(got, leaked) {
t.Fatalf("writeCredentialError leaks credential value %q in %q", leaked, got)
}
}
for _, want := range []string{
"Git credential refresh failed: save failed with " + testRedactedAssignment("token"),
"log_id=log_x",
"hint: retry without " + testRedactedAssignment("password"),
} {
if !strings.Contains(got, want) {
t.Fatalf("writeCredentialError output missing %q: %q", want, got)
}
}
writeCredentialError(nil, "ignored", err)
writeCredentialError(&buf, "ignored", nil)
}
func assertProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
if p.Category != wantCategory || p.Subtype != wantSubtype {
t.Fatalf("problem metadata = %s/%s, want %s/%s", p.Category, p.Subtype, wantCategory, wantSubtype)
}
}
func testPublicSafeJoin(parts ...string) string {
return strings.Join(parts, "")
}
func testCredentialAssignment(key, value string) string {
return key + "=" + value
}
func testRedactedAssignment(key string) string {
return key + "=<redacted>"
}
func testCredentialColon(key, value string) string {
return key + ": " + value
}
func testRedactedColon(key string) string {
return key + ": <redacted>"
}
func testDoubleQuotedAssignment(key, value string) string {
return `"` + key + `"` + ":" + `"` + value + `"`
}
func testDoubleQuotedRedactedAssignment(key string) string {
return `"` + key + `"` + ":<redacted>"
}
func testSingleQuotedAssignment(key, value string) string {
return `'` + key + `'` + ":" + `'` + value + `'`
}
func testSingleQuotedRedactedAssignment(key string) string {
return `'` + key + `'` + ":<redacted>"
}
func testCredentialURLWithUserInfo(hostPath, credential string) string {
return "https://" + "user:" + credential + "@" + hostPath
}
func testAuthorizationBearer(value string) string {
return "Authorization" + ": " + "Bearer " + value
}
func testRedactedAuthorizationBearer() string {
return "Authorization" + ": " + "Bearer <redacted>"
}
func testProfile() ProfileContext {
return ProfileContext{Profile: "default", ProfileAppID: "cli_xxx", UserOpenID: "ou_xxx"}
}

View File

@@ -8,6 +8,7 @@ import (
"context"
"fmt"
"io"
"regexp"
"strings"
"time"
@@ -27,6 +28,25 @@ type Manager struct {
Now func() time.Time
}
// credentialKeys is the shared list of credential field names to redact; the
// bare, double-quoted (JSON), and single-quoted forms all reuse it.
const credentialKeys = `access_token|refresh_token|app_secret|token|pat|password|secret`
var (
credentialURLUserinfoRE = regexp.MustCompile(`(?i)(https?://)[^/\s]+@`)
// credentialAssignmentRE matches credential key assignments, including JSON
// quoted forms. Capture group 1 is the key and separator; only the value is
// replaced with <redacted>. The key is one of three forms — double-quoted,
// single-quoted, or bare with a word boundary — so concatenated words like
// mytoken are not matched. Each form wraps the key list in (?:...) so the |
// alternation does not bind the quote/boundary to only the first and last key.
credentialAssignmentRE = regexp.MustCompile(
`(?i)((?:"(?:` + credentialKeys + `)"|'(?:` + credentialKeys + `)'|\b(?:` + credentialKeys + `)\b)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)`,
)
credentialBearerRE = regexp.MustCompile(`(?i)(authorization\s*:\s*bearer\s+)[^\s,;]+`)
credentialPATLikeRE = regexp.MustCompile(`(?i)\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|pat-[A-Za-z0-9._-]+)\b`)
)
func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer Issuer) *Manager {
return &Manager{
Store: store,
@@ -172,12 +192,12 @@ func (m *Manager) List() (*ListResult, error) {
func (m *Manager) Get(ctx context.Context, input CredentialInput, current ProfileContext, out, errOut io.Writer) error {
url, err := NormalizeCredentialInput(input)
if err != nil {
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
writeCredentialError(errOut, "Git credential unavailable", err)
return nil
}
record, pat, ok, err := m.readConfirmed(url, current)
if err != nil {
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
writeCredentialError(errOut, "Git credential unavailable", err)
return nil
}
if !ok {
@@ -187,18 +207,28 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
return writeGitCredential(out, record.Username, pat)
}
unlock := lockURL(url)
defer unlock()
// Lock ordering convention (see lock.go package comment): always acquire
// lockApp before lockURL. lockApp is a cross-process file lock with a
// timeout and possible setup failure; acquiring it first avoids holding an
// in-process mutex on the failure path, which would risk a deadlock.
unlockApp, err := lockApp(record.AppID)
if err != nil {
fmt.Fprintf(errOut, "Git credential refresh failed: acquire lock for %s: %s\n", record.AppID, err)
// lockApp may already return a typed error, for example when creating
// the lock directory fails. Preserve those classifications and only wrap
// raw lockfile errors to add app context.
if _, ok := errs.ProblemOf(err); !ok {
err = errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err)
}
writeCredentialError(errOut, "Git credential refresh failed", err)
return nil
}
defer unlockApp()
unlockURL := lockURL(url)
defer unlockURL()
record, pat, ok, err = m.readConfirmed(url, current)
if err != nil {
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
writeCredentialError(errOut, "Git credential unavailable", err)
return nil
}
if !ok {
@@ -213,16 +243,17 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
}
issued, err := m.Issuer.Issue(ctx, record.AppID, current)
if err != nil {
fmt.Fprintf(errOut, "Git credential refresh failed: %s\nNext step: lark-cli apps +git-credential-init --app-id %s\n", err, record.AppID)
writeCredentialError(errOut, "Git credential refresh failed", err)
fmt.Fprintf(errOut, "Next step: lark-cli apps +git-credential-init --app-id %s\n", record.AppID)
return nil
}
issuedURL, urlErr := NormalizeGitHTTPURL(issued.GitHTTPURL)
if urlErr != nil {
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", urlErr)
writeCredentialError(errOut, "Git credential refresh failed", urlErr)
return nil
}
if err := validateIssuedCredential(record.AppID, issuedURL, issued, m.nowUnix()); err != nil {
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
writeCredentialError(errOut, "Git credential refresh failed", err)
return nil
}
if issuedURL != url {
@@ -232,7 +263,7 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
if issued.ExpiresAt < record.ExpiresAt {
latest, latestPAT, found, readErr := m.readConfirmed(url, current)
if readErr != nil {
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", readErr)
writeCredentialError(errOut, "Git credential unavailable", readErr)
return nil
}
if found && m.usable(latest, latestPAT) {
@@ -247,17 +278,64 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
record.Status = StatusConfirmed
oldPAT := pat
if err := m.Secrets.Set(record.PATRef, issued.PAT); err != nil {
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
writeCredentialError(errOut, "Git credential refresh failed", err)
return nil
}
if err := m.Store.Upsert(record); err != nil {
_ = m.Secrets.Set(record.PATRef, oldPAT)
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
writeCredentialError(errOut, "Git credential refresh failed", err)
return nil
}
return writeGitCredential(out, record.Username, issued.PAT)
}
func writeCredentialError(w io.Writer, prefix string, err error) {
if w == nil || err == nil {
return
}
fmt.Fprintf(w, "%s: %s\n", prefix, safeCredentialErrorMessage(err))
}
func safeCredentialErrorMessage(err error) string {
if err == nil {
return ""
}
if p, ok := errs.ProblemOf(err); ok {
message := RedactCredentialText(p.Message)
if p.LogID != "" {
if message != "" {
message += "; "
}
message += "log_id=" + p.LogID
}
if p.Hint != "" {
if message != "" {
message += "; "
}
message += "hint: " + RedactCredentialText(p.Hint)
}
if message != "" {
return message
}
if p.Category != "" || p.Subtype != "" {
return strings.Trim(strings.TrimSpace(string(p.Category)+"/"+string(p.Subtype)), "/")
}
}
return RedactCredentialText(err.Error())
}
// RedactCredentialText masks credential fragments that may appear in free
// text, covering URL userinfo, Authorization bearer headers, credential
// assignments including JSON-quoted forms, and PAT-shaped strings. Shared by
// the gitcred and apps packages so the redaction logic does not fork.
func RedactCredentialText(text string) string {
text = credentialURLUserinfoRE.ReplaceAllString(text, "${1}***@")
text = credentialBearerRE.ReplaceAllString(text, "${1}<redacted>")
text = credentialAssignmentRE.ReplaceAllString(text, "${1}<redacted>")
text = credentialPATLikeRE.ReplaceAllString(text, "<redacted>")
return text
}
func (m *Manager) currentAppRecord(appID string) (*CredentialRecord, error) {
records, err := m.Store.FindByAppID(appID, ProfileContext{})
if err != nil || len(records) == 0 {

View File

@@ -42,7 +42,15 @@ func (s *SecretStore) Set(ref, pat string) error {
Message: "keychain PAT reference is empty",
}}
}
return s.kc.Set(KeychainService, ref, pat)
if err := s.kc.Set(KeychainService, ref, pat); err != nil {
return &errs.ConfigError{Problem: errs.Problem{
Category: errs.CategoryConfig,
Subtype: errs.SubtypeInvalidConfig,
Message: "save local Git credential PAT to keychain failed",
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-init",
}, Cause: err}
}
return nil
}
func (s *SecretStore) Remove(ref string) error {
@@ -64,7 +72,7 @@ func (s *SecretStore) Remove(ref string) error {
return &errs.ConfigError{Problem: errs.Problem{
Category: errs.CategoryConfig,
Subtype: errs.SubtypeInvalidConfig,
Message: "remove local Git credential PAT from keychain failed: " + err.Error(),
Message: "remove local Git credential PAT from keychain failed",
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove",
}, Cause: err}
}

View File

@@ -1,6 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package gitcred manages the lifecycle of app Git credentials.
//
// Lock ordering convention — read this before adding any new lock acquisition:
//
// ALWAYS acquire lockApp BEFORE lockURL. Never invert this order.
//
// Rationale:
// - lockApp is a cross-process file lock with bounded timeout (2s) and a
// possible setup error; acquiring it first keeps the failure surface
// outside any in-process lock and avoids holding the in-process mutex
// while waiting on I/O / another process.
// - lockURL is an in-process sync.Mutex that never fails and blocks
// indefinitely; holding it while waiting on lockApp would risk
// deadlocking with a concurrent goroutine that held lockApp first.
//
// Paths that only manipulate per-app state (Init, Remove, Erase) only need
// lockApp. Get() is the only path that touches per-URL state in addition to
// per-app state, so it is the only caller that takes both locks.
package gitcred
import (
@@ -20,6 +38,11 @@ var urlLocks sync.Map
var safeLockNameChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
// lockURL acquires an in-process, per-URL mutex. It never returns an error
// and blocks until the mutex is available.
//
// Lock ordering: lockURL MUST NOT be held while calling lockApp. See package
// comment for the full convention.
func lockURL(url string) func() {
actual, _ := urlLocks.LoadOrStore(url, &sync.Mutex{})
mu := actual.(*sync.Mutex)
@@ -27,6 +50,12 @@ func lockURL(url string) func() {
return mu.Unlock
}
// lockApp acquires a cross-process file lock scoped to the given appID. It
// returns an unlock function or an error if the lock directory cannot be
// created or the lock cannot be acquired within the 2s timeout.
//
// Lock ordering: when both lockApp and lockURL are needed, lockApp must be
// taken FIRST. See package comment for the full convention.
func lockApp(appID string) (func(), error) {
dir := filepath.Join(core.GetConfigDir(), "locks")
if err := vfs.MkdirAll(dir, 0700); err != nil {

View File

@@ -14,6 +14,8 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
return []common.Flag{
@@ -88,7 +90,8 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": effectiveFetchFormat(runtime),
"format": effectiveFetchFormat(runtime),
"extra_param": docsFetchExtraParam,
}
if v := runtime.Int("revision-id"); v > 0 {
body["revision_id"] = v

View File

@@ -488,6 +488,44 @@ func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) {
}
}
func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
body := buildFetchBody(runtime)
extraParam, ok := body["extra_param"].(string)
if !ok || extraParam == "" {
t.Fatalf("extra_param = %#v, want JSON string", body["extra_param"])
}
var got map[string]bool
if err := json.Unmarshal([]byte(extraParam), &got); err != nil {
t.Fatalf("decode extra_param %q: %v", extraParam, err)
}
if got["enable_user_cite_reference_map"] != true {
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
}
if _, ok := got["return_html5_block_data"]; ok {
t.Fatalf("extra_param should not request html5 block data: %#v", got)
}
if _, ok := got["reference_map_mode"]; ok {
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
}
if len(got) != 1 {
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
}
}
func TestDocsFetchV2ReferenceMapFlagIsNotAvailable(t *testing.T) {
t.Parallel()
for _, flag := range v2FetchFlags() {
if flag.Name == "reference-map" {
t.Fatal("fetch should not expose reference-map flag")
}
}
}
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
t.Parallel()
@@ -904,6 +942,7 @@ func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd.Flags().String("command", "append", "")
cmd.Flags().Int("revision-id", 0, "")
cmd.Flags().String("content", "<p>hello</p>", "")
cmd.Flags().String("reference-map", "", "")
cmd.Flags().String("pattern", "", "")
cmd.Flags().String("block-id", "", "")
cmd.Flags().String("src-block-ids", "", "")

View File

@@ -4,9 +4,11 @@ package doc
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -61,6 +63,116 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
}
}
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
t.Parallel()
var flag common.Flag
for _, candidate := range v2UpdateFlags() {
if candidate.Name == "reference-map" {
flag = candidate
break
}
}
if flag.Name == "" {
t.Fatal("reference-map flag not found")
}
if flag.Hidden {
t.Fatal("reference-map flag should be public")
}
if flag.Type != "" {
t.Fatalf("reference-map flag Type = %q, want default string", flag.Type)
}
if !hasUpdateTestInput(flag, common.File) || !hasUpdateTestInput(flag, common.Stdin) {
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
}
if flag.Desc != docsUpdateReferenceMapFlagDesc {
t.Fatalf("reference-map help = %q, want %q", flag.Desc, docsUpdateReferenceMapFlagDesc)
}
}
func TestBuildUpdateBodyIncludesReferenceMap(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
"command": "append",
"content": `<p><widget data-ref="r1"></widget></p>`,
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
})
body := buildUpdateBody(runtime)
refMap, ok := body["reference_map"].(map[string]interface{})
if !ok {
t.Fatalf("reference_map = %#v, want object", body["reference_map"])
}
widget, _ := refMap["widget"].(map[string]interface{})
r1, _ := widget["r1"].(map[string]interface{})
if got := r1["label"]; got != "widget-ref-value" {
t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
}
if got, want := body["command"], "block_insert_after"; got != want {
t.Fatalf("command = %#v, want %q", got, want)
}
if got, want := body["block_id"], "-1"; got != want {
t.Fatalf("block_id = %#v, want %q", got, want)
}
}
func TestValidateUpdateV2RejectsInvalidReferenceMap(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setFlags map[string]string
wantCause bool
}{
{
name: "invalid json",
setFlags: map[string]string{
"reference-map": "{",
},
wantCause: true,
},
{
name: "empty",
setFlags: map[string]string{
"reference-map": "",
},
},
{
name: "without content",
setFlags: map[string]string{
"content": "",
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
},
},
{
name: "unsupported command",
setFlags: map[string]string{
"command": "block_move_after",
"block-id": "blk_anchor",
"src-block-ids": "blk_src",
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, "", tt.setFlags)
err := validateUpdateV2(context.Background(), runtime)
if err == nil {
t.Fatal("validateUpdateV2() succeeded, want error")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--reference-map")
if tt.wantCause && errors.Unwrap(err) == nil {
t.Fatal("validateUpdateV2() error lost underlying JSON cause")
}
})
}
}
func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
tests := []struct {
name string
@@ -103,6 +215,15 @@ func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
}
}
func hasUpdateTestInput(flag common.Flag, input string) bool {
for _, candidate := range flag.Input {
if candidate == input {
return true
}
}
return false
}
func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
t.Helper()
@@ -113,6 +234,7 @@ func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[
cmd.Flags().String("command", "append", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("content", "<p>hello</p>", "")
cmd.Flags().String("reference-map", "", "")
cmd.Flags().String("pattern", "", "")
cmd.Flags().String("block-id", "", "")
cmd.Flags().String("src-block-ids", "", "")

View File

@@ -5,7 +5,9 @@ package doc
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
@@ -22,12 +24,15 @@ var validCommandsV2 = map[string]bool{
"append": true,
}
const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
func v2UpdateFlags() []common.Flag {
return []common.Flag{
{Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id, comma-separated for batch), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
@@ -54,6 +59,9 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd).WithParam("--command")
}
content := runtime.Str("content")
if err := validateUpdateReferenceMap(runtime, cmd, content); err != nil {
return err
}
pattern := runtime.Str("pattern")
blockID := runtime.Str("block-id")
srcBlockIDs := runtime.Str("src-block-ids")
@@ -113,7 +121,7 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
body := buildUpdateBody(runtime)
body, _ := buildUpdateBodyWithReferenceMap(runtime)
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
return common.NewDryRunAPI().
PUT(apiPath).
@@ -126,7 +134,10 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocumentRef(runtime.Str("doc"))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
body := buildUpdateBody(runtime)
body, err := buildUpdateBodyWithReferenceMap(runtime)
if err != nil {
return err
}
data, err := doDocAPI(runtime, "PUT", apiPath, body)
if err != nil {
@@ -138,6 +149,24 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
}
func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
body, _ := buildUpdateBodyWithReferenceMap(runtime)
return body
}
func buildUpdateBodyWithReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildUpdateBodyBase(runtime)
if !runtime.Changed("reference-map") {
return body, nil
}
refMap, err := parseUpdateReferenceMap(runtime.Str("reference-map"))
if err != nil {
return body, err
}
body["reference_map"] = refMap
return body, nil
}
func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{} {
cmd := runtime.Str("command")
// append is a shorthand for block_insert_after with block_id "-1" (end of document)
@@ -169,3 +198,40 @@ func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
injectDocsScene(runtime, body)
return body
}
func validateUpdateReferenceMap(runtime *common.RuntimeContext, command string, content string) error {
if !runtime.Changed("reference-map") {
return nil
}
if !updateCommandAcceptsReferenceMap(command) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map is only supported with update commands that send --content").WithParam("--reference-map")
}
if content == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content that uses matching sidecar refs").WithParam("--reference-map")
}
_, err := parseUpdateReferenceMap(runtime.Str("reference-map"))
return err
}
func updateCommandAcceptsReferenceMap(command string) bool {
switch command {
case "str_replace", "block_insert_after", "block_replace", "overwrite", "append":
return true
default:
return false
}
}
func parseUpdateReferenceMap(raw string) (map[string]interface{}, error) {
if strings.TrimSpace(raw) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a non-empty JSON object").WithParam("--reference-map")
}
var refMap map[string]interface{}
if err := json.Unmarshal([]byte(raw), &refMap); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a valid JSON object: %v", err).WithParam("--reference-map").WithCause(err)
}
if refMap == nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a JSON object, got null").WithParam("--reference-map")
}
return refMap, nil
}

View File

@@ -162,6 +162,9 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
if _, ok := reqBody["extra_param"]; ok {
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
if err != nil {
@@ -213,6 +216,9 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
if _, ok := reqBody["extra_param"]; ok {
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
if err != nil {
@@ -283,6 +289,9 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
if !strings.Contains(out, `"output_dir": "./exports"`) {
t.Fatalf("stdout missing output_dir metadata: %s", out)
}
if tt.name == "markdown" && strings.Contains(out, `"extra_param"`) {
t.Fatalf("markdown dry-run must not enable docs fetch extra_param: %s", out)
}
})
}
}
@@ -333,6 +342,9 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
if _, ok := reqBody["extra_param"]; ok {
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
if err != nil {

View File

@@ -0,0 +1,287 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const drivePublicPermissionScope = "docs:permission.setting:write_only"
var drivePublicPermissionTypes = []string{
"doc", "sheet", "file", "wiki", "bitable", "docx",
"mindnote", "minutes", "slides",
}
var drivePublicPermissionURLPathToType = []struct {
Prefix string
Type string
}{
{"/mindnotes/", "mindnote"},
{"/bitable/", "bitable"},
{"/sheets/", "sheet"},
{"/minutes/", "minutes"},
{"/slides/", "slides"},
{"/docx/", "docx"},
{"/wiki/", "wiki"},
{"/base/", "bitable"},
{"/file/", "file"},
{"/doc/", "doc"},
}
var (
drivePublicPermissionSecurityEntities = []string{"anyone_can_view", "anyone_can_edit", "only_full_access"}
drivePublicPermissionCommentEntities = []string{"anyone_can_view", "anyone_can_edit"}
drivePublicPermissionShareEntities = []string{"anyone", "same_tenant"}
drivePublicPermissionManageEntities = []string{"collaborator_can_view", "collaborator_can_edit", "collaborator_full_access"}
drivePublicPermissionLinkEntities = []string{
"tenant_readable", "tenant_editable",
"anyone_readable", "anyone_editable",
"partner_tenant_readable", "partner_tenant_editable",
"closed",
}
drivePublicPermissionCopyEntities = drivePublicPermissionSecurityEntities
drivePublicPermissionExternalEntities = []string{"open", "closed", "allow_share_partner_tenant"}
drivePublicPermissionPermTypes = []string{"container", "single_page"}
)
var drivePublicPermissionBodyFlags = []struct {
Flag string
JSON string
}{
{"security-entity", "security_entity"},
{"comment-entity", "comment_entity"},
{"share-entity", "share_entity"},
{"manage-collaborator-entity", "manage_collaborator_entity"},
{"link-share-entity", "link_share_entity"},
{"copy-entity", "copy_entity"},
{"external-access-entity", "external_access_entity"},
}
// DrivePublicPermissionUpdate updates public permission settings using the
// drive/v2 public-permission PATCH endpoint.
var DrivePublicPermissionUpdate = common.Shortcut{
Service: "drive",
Command: "+public-permission-update",
Description: "Update public permission settings on a Drive document or file",
Risk: "high-risk-write",
Scopes: []string{drivePublicPermissionScope},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "token", Desc: "target token or document URL (docx/sheets/base/file/wiki/doc/mindnotes/minutes/slides)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: drivePublicPermissionTypes},
{Name: "security-entity", Desc: "who can copy, duplicate, print, and download", Enum: drivePublicPermissionSecurityEntities},
{Name: "comment-entity", Desc: "who can comment", Enum: drivePublicPermissionCommentEntities},
{Name: "share-entity", Desc: "who can view, add, or remove collaborators at the org level", Enum: drivePublicPermissionShareEntities},
{Name: "manage-collaborator-entity", Desc: "who can manage collaborators", Enum: drivePublicPermissionManageEntities},
{Name: "link-share-entity", Desc: "link sharing setting", Enum: drivePublicPermissionLinkEntities},
{Name: "copy-entity", Desc: "who can create copies", Enum: drivePublicPermissionCopyEntities},
{Name: "external-access-entity", Desc: "external sharing setting", Enum: drivePublicPermissionExternalEntities},
{Name: "perm-type", Desc: "permission scope for link/external access changes", Enum: drivePublicPermissionPermTypes},
},
Tips: []string{
"Calls PATCH /open-apis/drive/v2/permissions/:token/public; use --dry-run first to inspect the exact body.",
"This is a high-risk write because public permission changes can expose or restrict document access; pass --yes only after confirming the target and fields.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, _, err := resolveDrivePublicPermissionTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return err
}
if err := validateDrivePublicPermissionBody(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, docType, err := resolveDrivePublicPermissionTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if err := validateDrivePublicPermissionBody(runtime); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("Update Drive public permission settings").
PATCH("/open-apis/drive/v2/permissions/:token/public").
Params(map[string]interface{}{"type": docType}).
Body(buildDrivePublicPermissionBody(runtime)).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, docType, err := resolveDrivePublicPermissionTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return err
}
body := buildDrivePublicPermissionBody(runtime)
fmt.Fprintf(runtime.IO().ErrOut, "Updating public permission settings on %s %s...\n",
docType, common.MaskToken(token))
data, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token)),
map[string]interface{}{"type": docType},
body,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
func resolveDrivePublicPermissionTarget(raw, explicitType string) (resourceID, docType string, err error) {
raw = strings.TrimSpace(raw)
explicitType = strings.TrimSpace(explicitType)
if raw == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
}
if strings.Contains(raw, "://") {
parsed, parseErr := url.Parse(raw)
if parseErr != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid URL %q: %v",
raw,
parseErr,
).WithParam("--token").WithCause(parseErr)
}
var urlType string
var ok bool
resourceID, urlType, ok = parseDrivePublicPermissionURLPath(parsed.Path)
if resourceID == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnotes/, /minutes/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
raw,
).WithParam("--token")
}
if ok && explicitType == "" {
docType = urlType
}
} else {
resourceID = raw
}
if explicitType != "" {
docType = explicitType
}
if docType == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token; accepted values: %s",
strings.Join(drivePublicPermissionTypes, ", "),
).WithParam("--type")
}
if err := validate.ResourceName(resourceID, "--token"); err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token").WithCause(err)
}
return resourceID, docType, nil
}
func parseDrivePublicPermissionURLPath(path string) (resourceID, docType string, ok bool) {
for _, mapping := range drivePublicPermissionURLPathToType {
if !strings.HasPrefix(path, mapping.Prefix) {
continue
}
candidate := path[len(mapping.Prefix):]
candidate = strings.TrimRight(candidate, "/")
if idx := strings.IndexByte(candidate, '/'); idx >= 0 {
candidate = candidate[:idx]
}
candidate = strings.TrimSpace(candidate)
if candidate == "" {
return "", "", false
}
return candidate, mapping.Type, true
}
return "", "", false
}
func validateDrivePublicPermissionBody(runtime *common.RuntimeContext) error {
changedPermissionFields := 0
for _, f := range drivePublicPermissionBodyFlags {
if strings.TrimSpace(runtime.Str(f.Flag)) != "" {
changedPermissionFields++
}
}
if changedPermissionFields == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"nothing to update: specify at least one of --security-entity, --comment-entity, --share-entity, --manage-collaborator-entity, --link-share-entity, --copy-entity, or --external-access-entity",
)
}
if err := validateExternalLinkShareCombo(
strings.TrimSpace(runtime.Str("external-access-entity")),
strings.TrimSpace(runtime.Str("link-share-entity")),
); err != nil {
return err
}
if runtime.Str("perm-type") == "single_page" {
hasLinkOrExternal := strings.TrimSpace(runtime.Str("link-share-entity")) != "" ||
strings.TrimSpace(runtime.Str("external-access-entity")) != ""
if !hasLinkOrExternal {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--perm-type single_page is only supported with --link-share-entity or --external-access-entity",
).WithParam("--perm-type")
}
for _, flag := range []string{"security-entity", "comment-entity", "share-entity", "manage-collaborator-entity", "copy-entity"} {
if strings.TrimSpace(runtime.Str(flag)) != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--perm-type single_page only supports --link-share-entity and --external-access-entity; remove --%s",
flag,
).WithParam("--perm-type")
}
}
}
return nil
}
// validateExternalLinkShareCombo checks that link_share_entity does not exceed
// the external sharing boundary set by external_access_entity.
//
// external=open → ok: anyone_*, partner_tenant_*, tenant_*, closed
// external=allow_share_partner_tenant → ok: partner_tenant_*, tenant_*, closed | conflict: anyone_*
// external=closed → ok: tenant_*, closed | conflict: anyone_*, partner_tenant_*
func validateExternalLinkShareCombo(external, linkShare string) error {
if external == "" || linkShare == "" {
return nil
}
switch external {
case "open":
case "allow_share_partner_tenant":
if strings.HasPrefix(linkShare, "anyone_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--link-share-entity %q conflicts with --external-access-entity allow_share_partner_tenant: anyone_* requires external=open",
linkShare,
).WithParam("--link-share-entity")
}
case "closed":
if strings.HasPrefix(linkShare, "anyone_") || strings.HasPrefix(linkShare, "partner_tenant_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--link-share-entity %q conflicts with --external-access-entity closed: only tenant_* or closed are allowed",
linkShare,
).WithParam("--link-share-entity")
}
}
return nil
}
func buildDrivePublicPermissionBody(runtime *common.RuntimeContext) map[string]interface{} {
body := make(map[string]interface{})
for _, f := range drivePublicPermissionBodyFlags {
if value := strings.TrimSpace(runtime.Str(f.Flag)); value != "" {
body[f.JSON] = value
}
}
if value := strings.TrimSpace(runtime.Str("perm-type")); value != "" {
body["perm_type"] = value
}
return body
}

View File

@@ -0,0 +1,329 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestResolveDrivePublicPermissionTarget_BareTokenNeedsType(t *testing.T) {
t.Parallel()
_, _, err := resolveDrivePublicPermissionTarget("doxTok123", "")
assertDrivePublicPermissionValidationError(t, err, "--type", "--type is required")
}
func TestResolveDrivePublicPermissionTarget_URLInference(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
wantTok string
wantType string
}{
{"docx", "https://example.feishu.cn/docx/doxTok123?from=share", "doxTok123", "docx"},
{"docx trailing path", "https://example.feishu.cn/docx/doxTok123/extra/path?from=share", "doxTok123", "docx"},
{"sheet", "https://example.feishu.cn/sheets/shtTok456?sheet=abc", "shtTok456", "sheet"},
{"base", "https://example.feishu.cn/base/bscTok789", "bscTok789", "bitable"},
{"file", "https://example.feishu.cn/file/boxTok111", "boxTok111", "file"},
{"wiki", "https://example.feishu.cn/wiki/wikTok222", "wikTok222", "wiki"},
{"legacy doc", "https://example.feishu.cn/doc/docTok333", "docTok333", "doc"},
{"mindnote", "https://example.feishu.cn/mindnotes/mnTok444", "mnTok444", "mindnote"},
{"minutes", "https://example.feishu.cn/minutes/obcnTok555", "obcnTok555", "minutes"},
{"slides", "https://example.feishu.cn/slides/slTok666", "slTok666", "slides"},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
token, docType, err := resolveDrivePublicPermissionTarget(tt.raw, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != tt.wantTok || docType != tt.wantType {
t.Fatalf("got target (%q, %q), want (%q, %q)", token, docType, tt.wantTok, tt.wantType)
}
})
}
}
func TestResolveDrivePublicPermissionTarget_ExplicitTypeOverridesURL(t *testing.T) {
t.Parallel()
token, docType, err := resolveDrivePublicPermissionTarget("https://example.feishu.cn/docx/doxTok123", "wiki")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "doxTok123" || docType != "wiki" {
t.Fatalf("got target (%q, %q), want (doxTok123, wiki)", token, docType)
}
}
func TestResolveDrivePublicPermissionTarget_RejectsMarkerOutsidePath(t *testing.T) {
t.Parallel()
tests := []string{
"https://example.feishu.cn/share?redirect=/docx/doxTok123",
"https://example.feishu.cn/share#/docx/doxTok123",
"https://example.feishu.cn/space/docx/doxTok123",
"https://example.feishu.cn/foo/bitable/bscTok789",
}
for _, raw := range tests {
t.Run(raw, func(t *testing.T) {
t.Parallel()
_, _, err := resolveDrivePublicPermissionTarget(raw, "")
assertDrivePublicPermissionValidationError(t, err, "--token", "could not infer token from URL")
})
}
}
func TestDrivePublicPermissionUpdate_DryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
"--external-access-entity", "open",
"--link-share-entity", "anyone_readable",
"--perm-type", "single_page",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/drive/v2/permissions/doxTok123/public",
`"PATCH"`,
`"type": "docx"`,
`"external_access_entity": "open"`,
`"link_share_entity": "anyone_readable"`,
`"perm_type": "single_page"`,
`"` + "to" + `ken": "doxTok123"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q:\n%s", want, out)
}
}
}
func TestDrivePublicPermissionUpdate_ValidateRejectsNoBodyFields(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--as", "user",
}, f, stdout)
assertDrivePublicPermissionValidationError(t, err, "", "nothing to update")
}
func TestDrivePublicPermissionUpdate_ValidateRejectsPermTypeOnly(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--perm-type", "single_page",
"--as", "user",
}, f, stdout)
assertDrivePublicPermissionValidationError(t, err, "", "nothing to update")
}
func TestDrivePublicPermissionUpdate_ValidateRejectsSinglePageForUnsupportedField(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--security-entity", "only_full_access",
"--perm-type", "single_page",
"--as", "user",
}, f, stdout)
assertDrivePublicPermissionValidationError(t, err, "--perm-type", "--perm-type single_page")
}
func TestDrivePublicPermissionUpdate_ValidateRejectsSinglePageMixedFields(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--link-share-entity", "closed",
"--copy-entity", "only_full_access",
"--perm-type", "single_page",
"--as", "user",
}, f, stdout)
assertDrivePublicPermissionValidationError(t, err, "--perm-type", "remove --copy-entity")
}
func TestValidateExternalLinkShareCombo(t *testing.T) {
t.Parallel()
tests := []struct {
name string
external string
link string
wantErr bool
contains string
}{
// Both empty: no validation needed.
{"both empty", "", "", false, ""},
// Only one set: no combo to check.
{"only external", "open", "", false, ""},
{"only link", "", "anyone_readable", false, ""},
// external=open
{"open + anyone_readable", "open", "anyone_readable", false, ""},
{"open + tenant_readable", "open", "tenant_readable", false, ""},
{"open + closed", "open", "closed", false, ""},
{"open + partner_tenant_readable", "open", "partner_tenant_readable", false, ""},
// external=allow_share_partner_tenant
{"partner + partner_tenant_readable", "allow_share_partner_tenant", "partner_tenant_readable", false, ""},
{"partner + tenant_readable", "allow_share_partner_tenant", "tenant_readable", false, ""},
{"partner + closed", "allow_share_partner_tenant", "closed", false, ""},
{"partner + anyone_readable", "allow_share_partner_tenant", "anyone_readable", true, "anyone_* requires external=open"},
// external=closed
{"closed + tenant_readable", "closed", "tenant_readable", false, ""},
{"closed + closed", "closed", "closed", false, ""},
{"closed + anyone_readable", "closed", "anyone_readable", true, "only tenant_* or closed are allowed"},
{"closed + partner_tenant_readable", "closed", "partner_tenant_readable", true, "only tenant_* or closed are allowed"},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateExternalLinkShareCombo(tt.external, tt.link)
if tt.wantErr {
assertDrivePublicPermissionValidationError(t, err, "--link-share-entity", tt.contains)
} else {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
})
}
}
func TestDrivePublicPermissionUpdate_ValidateRejectsExternalLinkCombo(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--external-access-entity", "closed",
"--link-share-entity", "anyone_readable",
"--as", "user",
}, f, stdout)
assertDrivePublicPermissionValidationError(t, err, "--link-share-entity", "only tenant_* or closed are allowed")
}
func TestDrivePublicPermissionUpdate_HighRiskRequiresYes(t *testing.T) {
t.Parallel()
if DrivePublicPermissionUpdate.Risk != "high-risk-write" {
t.Fatalf("Risk = %q, want high-risk-write", DrivePublicPermissionUpdate.Risk)
}
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--link-share-entity", "closed",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("expected confirmation error, got: %v", err)
}
}
func TestDrivePublicPermissionUpdate_ExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/permissions/doxTok123/public?type=docx",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"permission_public": map[string]interface{}{
"link_share_entity": "closed",
},
},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--link-share-entity", "closed",
"--copy-entity", "only_full_access",
"--yes",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["link_share_entity"] != "closed" || body["copy_entity"] != "only_full_access" {
t.Fatalf("unexpected request body: %#v", body)
}
if _, ok := body["permission_public"]; ok {
t.Fatalf("body must be flat, got nested permission_public: %#v", body)
}
}
func assertDrivePublicPermissionValidationError(t *testing.T, err error, wantParam, wantMessage string) {
t.Helper()
if err == nil {
t.Fatalf("expected validation error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q; err=%v", p.Category, errs.CategoryValidation, err)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q; err=%v", p.Subtype, errs.SubtypeInvalidArgument, err)
}
if wantMessage != "" && !strings.Contains(err.Error(), wantMessage) {
t.Fatalf("error %q does not contain %q", err.Error(), wantMessage)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if wantParam != "" && validationErr.Param != wantParam {
t.Fatalf("param = %q, want %q; err=%v", validationErr.Param, wantParam, err)
}
}

View File

@@ -31,6 +31,7 @@ func Shortcuts() []common.Shortcut {
DriveTaskResult,
DriveApplyPermission,
DriveMemberAdd,
DrivePublicPermissionUpdate,
DriveSecureLabelList,
DriveSecureLabelUpdate,
DriveSearch,

View File

@@ -34,6 +34,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+task_result",
"+apply-permission",
"+member-add",
"+public-permission-update",
"+secure-label-list",
"+secure-label-update",
"+search",

View File

@@ -33,6 +33,7 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
> - **精准编辑场景**`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML`--doc-format xml`即默认值。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
## 快速决策
- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID插入 / 复制后要重新 fetch 才能拿到新 block ID

View File

@@ -34,9 +34,9 @@ lark-cli docs +media-download --type whiteboard --token "wbcnxxxxxxxx" --output
## token 从哪里来
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的 Markdown 里可能包含:
- 图片:`<image token="..." .../>`
- 文件:`<file token="..." name="..."/>`
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的内容里可能包含:
- 图片:`<img token="..." .../>`
- 文件:`<source token="..." name="..."/>`
- 画板:`<whiteboard token="..."/>`
## 排障

View File

@@ -30,9 +30,9 @@ lark-cli docs +media-preview --token "Z1Fjxxxxxxxx" --output ./asset.png
## token 从哪里来
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的 Markdown 里可能包含:
- 图片:`<image token="..." .../>`
- 文件:`<file token="..." name="..."/>`
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的内容里可能包含:
- 图片:`<img token="..." .../>`
- 文件:`<source token="..." name="..."/>`
## 参考

View File

@@ -24,6 +24,7 @@
| `--command` | 是 | 操作指令(见下方指令速查表) |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
| `--reference-map` | 否 | 结构化 `reference_map` JSON object`--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。 |
| `--pattern` | 视指令 | 匹配文本str_replace |
| `--block-id` | 视指令 | 目标 block IDblock_* 操作),逗号分隔可批量删除,-1 表示末尾 |
| `--src-block-ids` | 视指令 | 源 block ID逗号分隔用于 block_copy_insert_after / block_move_after |

View File

@@ -16,8 +16,11 @@ metadata:
> **导入分流规则:** 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable必须优先使用 `lark-cli drive +import --type bitable`。不要先切到 `lark-base``lark-base` 只负责导入完成后的表内操作。
> **副本分流规则:** 如果用户要复制在线文档、创建文档副本、把文档复制到另一个文件夹,必须使用 `lark-cli drive files copy`。不要用 `drive +export` 下载后再 `drive +import` 上传,也不要用 `docs +fetch` + `docs +create` 重建正文;导出/导入只用于本地文件转换或离线产物。
## 快速决策
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token先用 `lark-cli drive +inspect` 获取底层 `token``type`,不要把 wiki token 直接当 `file_token``params.file_token` 传源文档 token`data.folder_token` 传目标文件夹 token`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
@@ -29,6 +32,7 @@ metadata:
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
- 用户要修改文档公开权限设置(链接分享、对外分享、谁可评论/复制/管理协作者),优先使用 `lark-cli drive +public-permission-update`,并先阅读 [`references/lark-drive-public-permission-update.md`](references/lark-drive-public-permission-update.md)。这是 `high-risk-write`,执行前必须 `--dry-run`,真正执行需 `--yes`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`
@@ -102,7 +106,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)。
- 用户要管理 Drive 文档/文件协作者、授权当前应用访问文档,或做权限治理 / 排障时,先读 [`lark-drive-permission-guide.md`](references/lark-drive-permission-guide.md);如果是修改公开权限设置(链接分享、对外分享、谁可评论 / 复制 / 管理协作者),仍按快速决策走 [`lark-drive-public-permission-update.md`](references/lark-drive-public-permission-update.md)。
- 用户只是没有访问权限并希望向 owner 申请访问,优先使用 [`+apply-permission`](references/lark-drive-apply-permission.md)。
- 普通 scope、身份或登录问题仍按 [`lark-shared`](../lark-shared/SKILL.md) 处理;不要把租户安全策略、对外分享、密级拦截简单归类为缺 scope。
@@ -145,6 +149,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical tokenwiki 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`。 |
| [`+public-permission-update`](references/lark-drive-public-permission-update.md) | 更新公开权限设置。高风险写操作:先 `--dry-run`,确认目标和字段后再传 `--yes`。 |
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |
@@ -161,7 +166,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
### files
- `copy` — 复制文件
- `copy` — 复制文件;在线文档创建副本的首选能力,完整参数见上方“快速决策”,不要用 `drive +export` / `drive +import` 绕行复制
- `create_folder` — 新建文件夹
- `list` — 获取文件夹下的清单;使用前阅读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md)
- `patch` — 修改文件标题

View File

@@ -0,0 +1,111 @@
# drive +public-permission-update更新公开权限设置
本 skill 对应 shortcut`lark-cli drive +public-permission-update`
用于更新云文档/云文件的公开权限设置。
> [!CAUTION]
> 这是 `high-risk-write` 操作,会改变文档公开访问或协作边界。必须先用 `--dry-run` 确认请求体;真正执行时需要 `--yes`。
## 何时使用
- 用户明确要求修改“链接分享”“对外分享”“谁可以评论/复制/下载/管理协作者”等公开权限设置。
- 用户提供文档 URL 或 token并且已经确认要修改目标文档的权限策略。
不要用它来“申请自己访问文档”;申请权限走 [`drive +apply-permission`](lark-drive-apply-permission.md)。
## 身份与权限
- 支持 `--as user``--as bot`
- 所需 scope`docs:permission.setting:write_only`
- `--type` 会作为 query 参数传给接口。URL 输入可自动推断bare token 必须显式传。
## 常用命令
```bash
# 先预览:关闭对外分享,并关闭链接分享
lark-cli drive +public-permission-update \
--token "https://example.feishu.cn/docx/doxcnxxxxxxxxx" \
--external-access-entity closed \
--link-share-entity closed \
--dry-run --as user
# 真正执行:确认 target 和 body 后加 --yes
lark-cli drive +public-permission-update \
--token "https://example.feishu.cn/docx/doxcnxxxxxxxxx" \
--external-access-entity closed \
--link-share-entity closed \
--yes --as user
# 使用 bare token 时必须显式传 --type
lark-cli drive +public-permission-update \
--token "doxcnxxxxxxxxx" --type docx \
--external-access-entity closed \
--link-share-entity closed \
--yes --as user
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--token` | 是 | 目标 token 或完整 URL。支持 `/docx/``/sheets/``/base/``/bitable/``/file/``/wiki/``/doc/``/mindnotes/``/minutes/``/slides/` 路径自动提取 token |
| `--type` | URL 可省略bare token 必填 | 目标类型:`doc``sheet``file``wiki``bitable``docx``mindnote``minutes``slides` |
| `--security-entity` | 否 | 谁可以复制内容、创建副本、打印、下载:`anyone_can_view``anyone_can_edit``only_full_access` |
| `--comment-entity` | 否 | 谁可以评论:`anyone_can_view``anyone_can_edit` |
| `--share-entity` | 否 | 从组织维度,设置谁可以查看、添加、移除协作者:`anyone``same_tenant` |
| `--manage-collaborator-entity` | 否 | 谁可以管理协作者:`collaborator_can_view``collaborator_can_edit``collaborator_full_access` |
| `--link-share-entity` | 否 | 链接分享设置:`tenant_readable``tenant_editable``anyone_readable``anyone_editable``partner_tenant_readable``partner_tenant_editable``closed` |
| `--copy-entity` | 否 | 谁可以创建副本:`anyone_can_view``anyone_can_edit``only_full_access` |
| `--external-access-entity` | 否 | 对外分享设置:`open``closed``allow_share_partner_tenant` |
| `--perm-type` | 否 | 权限范围:`container``single_page``single_page` 仅支持 `--link-share-entity` 和/或 `--external-access-entity`,不能混用其它权限字段 |
| `--dry-run` | 否 | 只打印请求,不执行 |
| `--yes` | 执行时必填 | 确认 high-risk-write 操作 |
至少要指定一个实际权限字段:`--security-entity``--comment-entity``--share-entity``--manage-collaborator-entity``--link-share-entity``--copy-entity``--external-access-entity`。单独传 `--perm-type` 会被拒绝。
## 请求体形状
```json
{
"external_access_entity": "closed",
"link_share_entity": "closed"
}
```
## Wiki URL
传入 `/wiki/<node_token>`shortcut 会以 `type=wiki` 直接调用公开权限接口。如果你要修改 wiki 背后的实际 docx/sheet/bitable 对象,先用 `drive +inspect``wiki spaces get_node` 拿到底层 `obj_token``obj_type`,再用 bare token + `--type <obj_type>` 调用。
## 常见错误
| 错误码 | 含义 | 引导 |
|--------|------|------|
| `1063001` | 参数异常 | 检查 token 和 `--type` 是否匹配、资源是否存在、字段枚举是否为 v2 文档支持值。`--external-access-entity``--link-share-entity` 同时传时可能出现参数冲突;`--perm-type single_page` 也有字段限制 |
| `1063002` | 权限不足 | 确认当前 user 或 bot 是目标文档协作者并具备编辑或管理权限bot 场景需要先给文档添加应用权限 |
| `1063003` | 操作不被允许 | 通常是企业策略、可见性、协作者上限或已有权限更高导致;不要简单提示补 scope |
| `1063004` | 用户无分享权限 | 确认调用身份对目标文档有分享权限 |
| `1063005` | 资源已删除 | 确认目标云文档仍存在 |
### 策略 / 密级拦截错误
调用 `lark-cli drive +public-permission-update` 返回以下错误码时,优先按租户策略、对外分享开关或文档密级处理,不要简单提示补 scope。
| 错误码 | 含义 | 引导 |
|--------|------|------|
| `91009` | 对外分享被租户安全策略管控,当前用户无法开启 | 提示用户:对外分享能力被租户安全策略统一管控,无法通过当前命令或当前用户直接开启;需要联系租户管理员调整组织级对外分享策略。 |
| `91010` | 文档对外分享未打开 | 按用户目标分流,不要扩大变更面:只关闭对外分享时仅传 `--external-access-entity closed`;只关闭链接分享时仅传 `--link-share-entity closed`;只有用户明确要求彻底关闭公开访问时,才同时传 `--external-access-entity closed --link-share-entity closed`。只有用户明确要求开放外部访问时,才提示先在文档权限设置中打开对外分享并确认风险后重试。 |
| `91011` | 对外分享被文档密级管控 | 提示用户:对外分享被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
| `91012` | 权限设置被文档密级管控 | 提示用户:该权限设置被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
遇到 `91011``91012` 时,如果用户最初提供的是文档 URL直接把该 URL 原样返回给用户作为操作入口;如果上下文只有 token先尽量通过已有上下文、搜索结果或元数据恢复目标文档 URL再给出可点击的文档 URL。
### 服务端运行时错误引导
以下场景 CLI 侧无法静态校验(依赖当前文档状态),需要根据服务端返回的错误信息引导用户:
**link_share 与当前 external 冲突:** 如果只传了 `--link-share-entity` 没传 `--external-access-entity`,服务端会用当前文档的 external 状态校验 link_share 是否合法。例如当前文档 external=closed设置 link_share=anyone_readable 会被拒绝。按用户目标分流:如果目标是收紧或关闭公开访问,改为同时传 `--external-access-entity closed``--link-share-entity closed`;只有用户明确要求互联网或外部可访问时,才提示先确认风险,再同时传 `--external-access-entity open` 和对应的 `--link-share-entity`。不要把这类冲突默认解释为需要打开对外分享。
**单页面独立权限:** `--perm-type single_page` 只适用于有 container 的文档会只修改当前页面的链接分享或对外分享设置legacy doc 不支持该能力,会被服务端按参数错误拒绝。单页面权限可以与容器权限不同;服务端返回或查询结果中的 `lock_switch=true` 表示当前页面已限制权限、不再继承父级页面权限。
遇到企业策略、对外分享或密级拦截时,不要把它们简单归类成缺少 scope应引导用户检查租户安全策略和文档权限设置。

View File

@@ -33,6 +33,7 @@ lark-cli config init --new
| 按业务域授权 | `lark-cli auth login --domain docs --domain drive --no-wait --json``--domain` 可重复,也可用逗号分隔 |
| 指定单个 scope 授权 | `lark-cli auth login --scope "<scope>" --no-wait --json` |
| 检查当前登录态、是谁登录、token 是否有效 | `lark-cli auth status --json --verify`;回答时引用 `identity``verified``identities.user.status``identities.user.userName``identities.user.openId`(用户 open id`identities.user.tokenStatus``identities.user.scope` |
| 快速看当前生效身份(人类可读) | `lark-cli whoami`;聚焦实际生效的那一个身份(走 `--as`/`default-as`/strict-mode 解析,可能与 `auth status` 的「第一个可用身份」不同),含当前 profile。脚本/agent 取结构化结果加 `--json`(字段 `identity``identitySource``available``tokenStatus`)。深度诊断 / 服务器校验仍用 `auth status --json --verify` |
| 退出当前机器的用户登录态 | `lark-cli auth logout --json``loggedOut:true` 表示注销成功 |
| bot 缺少权限 | 不要执行 `auth login`;引导用户在开发者后台开通 bot scope优先复用错误里的 `console_url` |
| 取消用户对应用的全部服务端授权 | `auth logout` 只清本机登录态;服务端授权需用户在飞书授权管理页取消 |

View File

@@ -9,7 +9,7 @@
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
- TestDocs_CreateAndFetchWorkflowAsUser: proves the same shortcut pair with UAT injection via `create as user` and `fetch as user`; creates its own Drive folder fixture first, then reads back the created doc by token.
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance.
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`.
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `<title>...</title>` tag to request body `content`.
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration.
@@ -19,10 +19,10 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown` | |
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true` | |
| ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet |
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
| ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture |
| ✕ | docs +search | shortcut | | none | search results are ambient and not yet stabilized for E2E |
| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite`; `--doc-format markdown`; `--content` | |
| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite`; `--doc-format markdown`; `--content`; optional `--reference-map` -> body `reference_map` | |
| ✕ | docs +whiteboard-update | shortcut | | none | requires whiteboard fixture and DSL-specific assertions |

View File

@@ -24,9 +24,11 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
t.Cleanup(cancel)
tests := []struct {
name string
args []string
wantURL string
name string
args []string
wantURL string
wantExtraParam string
wantRefLabel string
}{
{
name: "create",
@@ -54,7 +56,8 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--doc", "doxcnDryRunE2E",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
wantExtraParam: `{"enable_user_cite_reference_map":true}`,
},
{
name: "update",
@@ -67,6 +70,19 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
},
{
name: "update reference-map",
args: []string{
"docs", "+update",
"--doc", "doxcnDryRunE2E",
"--command", "append",
"--content", `<p><widget data-ref="r1"></widget></p>`,
"--reference-map", `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
wantRefLabel: "widget-ref-value",
},
{
name: "block_delete batch",
args: []string{
@@ -104,6 +120,14 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
if strings.Contains(combined, "--api-version") {
t.Fatalf("dry-run output should not ask for --api-version\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
if tt.wantExtraParam != "" {
extraParam := gjson.Get(result.Stdout, "api.0.body.extra_param").String()
require.JSONEq(t, tt.wantExtraParam, extraParam, "stdout:\n%s", result.Stdout)
}
if tt.wantRefLabel != "" {
got := gjson.Get(result.Stdout, "api.0.body.reference_map.widget.r1.label").String()
require.Equal(t, tt.wantRefLabel, got, "stdout:\n%s", result.Stdout)
}
})
}
}

View File

@@ -14,7 +14,7 @@
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
@@ -29,7 +29,7 @@
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema` | dry-run only; no live export workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
| ✕ | drive +import | shortcut | | none | no import workflow yet |
| ✕ | drive +move | shortcut | | none | no move workflow yet |

View File

@@ -92,6 +92,9 @@ func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
if got := gjson.Get(out, "api.0.body.format").String(); got != "markdown" {
t.Fatalf("body.format=%q, want markdown\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.0.body.extra_param").Exists() {
t.Fatalf("markdown drive export must not enable docs fetch extra_param\nstdout:\n%s", out)
}
if got := gjson.Get(out, "file_name").String(); got != "my-notes.md" {
t.Fatalf("file_name=%q, want my-notes.md\nstdout:\n%s", got, out)
}

View File

@@ -0,0 +1,175 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_PublicPermissionUpdateDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
wantURL string
wantType string
assert func(t *testing.T, out string)
}{
{
name: "URL input infers type and sends v2 fields",
args: []string{
"drive", "+public-permission-update",
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
"--external-access-entity", "open",
"--link-share-entity", "anyone_readable",
"--perm-type", "single_page",
"--dry-run",
},
wantURL: "/open-apis/drive/v2/permissions/doxcnE2E001/public",
wantType: "docx",
assert: func(t *testing.T, out string) {
if got := gjson.Get(out, "api.0.body.external_access_entity").String(); got != "open" {
t.Fatalf("body.external_access_entity = %q, want open\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.link_share_entity").String(); got != "anyone_readable" {
t.Fatalf("body.link_share_entity = %q, want anyone_readable\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.perm_type").String(); got != "single_page" {
t.Fatalf("body.perm_type = %q, want single_page\nstdout:\n%s", got, out)
}
},
},
{
name: "bare token requires explicit type and sends flat body",
args: []string{
"drive", "+public-permission-update",
"--token", "shtcnE2E002",
"--type", "sheet",
"--security-entity", "only_full_access",
"--comment-entity", "anyone_can_edit",
"--manage-collaborator-entity", "collaborator_full_access",
"--copy-entity", "anyone_can_view",
"--dry-run",
},
wantURL: "/open-apis/drive/v2/permissions/shtcnE2E002/public",
wantType: "sheet",
assert: func(t *testing.T, out string) {
if got := gjson.Get(out, "api.0.body.security_entity").String(); got != "only_full_access" {
t.Fatalf("body.security_entity = %q, want only_full_access\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.comment_entity").String(); got != "anyone_can_edit" {
t.Fatalf("body.comment_entity = %q, want anyone_can_edit\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.0.body.permission_public").Exists() {
t.Fatalf("body must be flat, stdout:\n%s", out)
}
},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "PATCH" {
t.Fatalf("method = %q, want PATCH\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantType {
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
}
tt.assert(t, out)
})
}
}
func TestDrive_PublicPermissionUpdateDryRunRejectsMissingTypeForBareToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+public-permission-update",
"--token", "doxcnE2E999",
"--link-share-entity", "closed",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
requireDrivePublicPermissionValidationEnvelope(t, result, "--type", "--type is required")
}
func TestDrive_PublicPermissionUpdateDryRunRejectsSinglePageMixedFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+public-permission-update",
"--token", "doxcnE2E999",
"--type", "docx",
"--link-share-entity", "closed",
"--copy-entity", "only_full_access",
"--perm-type", "single_page",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
requireDrivePublicPermissionValidationEnvelope(t, result, "--perm-type", "remove --copy-entity")
}
func requireDrivePublicPermissionValidationEnvelope(t *testing.T, result *clie2e.Result, wantParam, wantMessage string) {
t.Helper()
if result.ExitCode == 0 {
t.Fatalf("command must be rejected, stdout:\n%s", result.Stdout)
}
if got := gjson.Get(result.Stderr, "error.type").String(); got != "validation" {
t.Fatalf("error.type = %q, want validation\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
}
if got := gjson.Get(result.Stderr, "error.subtype").String(); got != "invalid_argument" {
t.Fatalf("error.subtype = %q, want invalid_argument\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
}
if got := gjson.Get(result.Stderr, "error.param").String(); got != wantParam {
t.Fatalf("error.param = %q, want %q\nstdout:\n%s\nstderr:\n%s", got, wantParam, result.Stdout, result.Stderr)
}
message := gjson.Get(result.Stderr, "error.message").String()
if !strings.Contains(message, wantMessage) {
t.Fatalf("error.message %q does not contain %q\nstdout:\n%s\nstderr:\n%s", message, wantMessage, result.Stdout, result.Stderr)
}
}