Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testi
t.Errorf("Subtype = %q, want %q", problem.Subtype, errs.SubtypeUnknown)
}

if !strings.Contains(problem.Hint, `auth login --scope "docx:document:create" --no-wait --json`) {
if !strings.Contains(problem.Hint, `auth login --scope "docx:document:create docs:document.media:upload docx:document:write_only docx:document:readonly" --no-wait --json`) {
t.Errorf("expected shortcut scoped recovery, got %q", problem.Hint)
}
}
Expand Down Expand Up @@ -699,7 +699,12 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
t.Errorf("rendered error lost need-authorization cause %v: %v", authErr.Cause, rendered)
}

want := "existing hint\n" + recovery.UserAuthorization("docx:document:create").String()
want := "existing hint\n" + recovery.UserAuthorization(
"docx:document:create",
"docs:document.media:upload",
"docx:document:write_only",
"docx:document:readonly",
).String()
if problem.Hint != want {
t.Errorf("expected appended hint %q, got %q", want, problem.Hint)
}
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ require (
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
github.com/yuin/goldmark v1.7.16
github.com/zalando/go-keyring v0.2.8
golang.org/x/image v0.24.0
golang.org/x/net v0.33.0
golang.org/x/sync v0.15.0
golang.org/x/sys v0.33.0
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
Expand All @@ -139,6 +141,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
Expand Down
26 changes: 20 additions & 6 deletions internal/credential/credential_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ type CredentialProvider struct {
accountOnce sync.Once
account *Account
accountErr error
selectedMu sync.RWMutex
selectedSource credentialSource

hintOnce sync.Once
Expand Down Expand Up @@ -191,7 +192,7 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er
internal.UserOpenId = ""
internal.UserName = ""
}
p.selectedSource = source
p.setSelectedCredentialSource(source)
return internal, nil
}
}
Expand All @@ -200,7 +201,7 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er
if err != nil {
return nil, err
}
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
p.setSelectedCredentialSource(defaultTokenSource{resolver: p.defaultToken})
return acct, nil
}
return nil, core.NotConfiguredError()
Expand Down Expand Up @@ -239,19 +240,32 @@ func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account,
}

func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) {
if p.selectedSource != nil {
return p.selectedSource, nil
if source := p.getSelectedCredentialSource(); source != nil {
return source, nil
}
if p.defaultAcct == nil {
return nil, nil
}
if _, err := p.ResolveAccount(ctx); err != nil {
return nil, err
}
if p.selectedSource == nil {
source := p.getSelectedCredentialSource()
if source == nil {
return nil, fmt.Errorf("credential provider resolved an account without selecting a token source")
}
return p.selectedSource, nil
return source, nil
}

func (p *CredentialProvider) getSelectedCredentialSource() credentialSource {
p.selectedMu.RLock()
defer p.selectedMu.RUnlock()
return p.selectedSource
}

func (p *CredentialProvider) setSelectedCredentialSource(source credentialSource) {
p.selectedMu.Lock()
defer p.selectedMu.Unlock()
p.selectedSource = source
}

func resolveTokenFromSource(ctx context.Context, source credentialSource, req TokenSpec) (*TokenResult, error) {
Expand Down
86 changes: 67 additions & 19 deletions shortcuts/common/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
Factory *cmdutil.Factory // injected by framework
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
larkSDK *lark.Client // eagerly initialized in mountDeclarative
larkSDK *lark.Client // initialized for non-local shortcut execution
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
}

Expand Down Expand Up @@ -195,7 +195,7 @@
return result.Token, nil
}

// LarkSDK returns the eagerly-initialized Lark SDK client.
// LarkSDK returns the initialized Lark SDK client for non-local execution.
func (ctx *RuntimeContext) LarkSDK() *lark.Client {
return ctx.larkSDK
}
Expand Down Expand Up @@ -711,6 +711,13 @@
ctx.outputErrOnce.Do(func() { ctx.outputErr = err })
}

// OutputError returns the first deferred output failure captured by Out,
// OutRaw, or OutFormat. Commands that create local artifacts can use it to
// roll those artifacts back before returning the final command error.
func (ctx *RuntimeContext) OutputError() error {
return ctx.outputErr

Check warning on line 718 in shortcuts/common/runner.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/runner.go#L717-L718

Added lines #L717 - L718 were not covered by tests
}

func wrapLegacyPrettyRenderer(prettyFn func(w io.Writer)) output.PrettyRenderer {
if prettyFn == nil {
return nil
Expand Down Expand Up @@ -766,6 +773,21 @@
return output.PartialFailure(output.ExitAPI)
}

// OutPartialFailureRaw is like OutPartialFailure but disables HTML escaping
// for payloads that contain document markup in warning or result fields.
func (ctx *RuntimeContext) OutPartialFailureRaw(data interface{}, meta *output.Meta) error {
ctx.handleEmitterError(ctx.newEmitter().PartialFailure(data, output.EmitOptions{
Format: "",
Raw: true,
JQ: ctx.JqExpr,
Meta: meta,
}))
if ctx.outputErr != nil {
return ctx.outputErr

Check warning on line 786 in shortcuts/common/runner.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/runner.go#L786

Added line #L786 was not covered by tests
}
return output.PartialFailure(output.ExitAPI)
}

// OutFormat prints output based on --format flag.
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
// When JqExpr is set, envelope filtering takes precedence over format.
Expand Down Expand Up @@ -928,23 +950,29 @@
return nil
}
}
as, err := resolveShortcutIdentity(cmd, f, s)
if err != nil {
return err
local := s.Local != nil && s.Local(cmd)
var (
as core.Identity
config *core.CliConfig
err error
)
if local {
as, err = resolveLocalShortcutIdentity(cmd, f, s)
config = &core.CliConfig{}

Check warning on line 961 in shortcuts/common/runner.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/runner.go#L960-L961

Added lines #L960 - L961 were not covered by tests
} else {
as, err = resolveShortcutIdentity(cmd, f, s)
if err == nil {
config, err = f.Config()
}
if err == nil {
err = checkShortcutScopes(f, cmd.Context(), as, config, s.ScopesForIdentity(string(as)))
}
}

config, err := f.Config()
if err != nil {
return err
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, as, config, false)

if err := checkShortcutScopes(f, cmd.Context(), as, config, s.ScopesForIdentity(string(as))); err != nil {
return err
}

rctx, err := newRuntimeContext(cmd, f, s, config, as, botOnly)
rctx, err := newRuntimeContext(cmd, f, s, config, as, botOnly, !local)
if err != nil {
return err
}
Expand Down Expand Up @@ -1006,6 +1034,24 @@
return as, nil
}

func resolveLocalShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (core.Identity, error) {
f.IdentityAutoDetected = false
asFlag, _ := cmd.Flags().GetString("as")
as := core.AsBot
if !slices.Contains(s.AuthTypes, string(core.AsBot)) && len(s.AuthTypes) > 0 {
as = core.Identity(s.AuthTypes[0])

Check warning on line 1042 in shortcuts/common/runner.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/runner.go#L1037-L1042

Added lines #L1037 - L1042 were not covered by tests
}
if cmd.Flags().Changed("as") && core.Identity(asFlag) != core.AsAuto && strings.TrimSpace(asFlag) != "" {
as = core.Identity(asFlag)
} else {
f.IdentityAutoDetected = true

Check warning on line 1047 in shortcuts/common/runner.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/runner.go#L1044-L1047

Added lines #L1044 - L1047 were not covered by tests
}
if err := f.CheckIdentity(as, s.AuthTypes); err != nil {
return "", err

Check warning on line 1050 in shortcuts/common/runner.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/runner.go#L1049-L1050

Added lines #L1049 - L1050 were not covered by tests
}
return as, nil

Check warning on line 1052 in shortcuts/common/runner.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/runner.go#L1052

Added line #L1052 was not covered by tests
}

func checkShortcutScopes(f *cmdutil.Factory, ctx context.Context, as core.Identity, config *core.CliConfig, scopes []string) error {
if len(scopes) == 0 {
return nil
Expand All @@ -1023,7 +1069,7 @@
WithMissingScopes(missing...)
}

func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) (*RuntimeContext, error) {
func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly, initializeLarkSDK bool) (*RuntimeContext, error) {
ctx := cmd.Context()
ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String())
rctx := &RuntimeContext{
Expand All @@ -1040,11 +1086,13 @@
})
rctx.botInfoFunc = sync.OnceValues(rctx.fetchBotInfo)

sdk, err := f.LarkClient()
if err != nil {
return nil, err
if initializeLarkSDK {
sdk, err := f.LarkClient()
if err != nil {
return nil, err

Check warning on line 1092 in shortcuts/common/runner.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/runner.go#L1092

Added line #L1092 was not covered by tests
}
rctx.larkSDK = sdk
}
rctx.larkSDK = sdk

applyJSONShorthand(cmd, s)
rctx.Format = rctx.Str("format")
Expand Down
2 changes: 1 addition & 1 deletion shortcuts/common/runner_error_presenter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ func TestNewRuntimeContextUsesEffectiveBotOnlyIdentityForDeclaredScopes(t *testi
t.Fatal(err)
}
cmd := newTestShortcutCmd(shortcut, f)
runtime, err := newRuntimeContext(cmd, f, shortcut, config, core.AsUser, true)
runtime, err := newRuntimeContext(cmd, f, shortcut, config, core.AsUser, true, true)
if err != nil {
t.Fatalf("newRuntimeContext() error = %v", err)
}
Expand Down
27 changes: 27 additions & 0 deletions shortcuts/common/runner_partial_failure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package common

import (
"bytes"
"context"
"encoding/json"
"errors"
Expand Down Expand Up @@ -61,3 +62,29 @@ func TestOutPartialFailure(t *testing.T) {
t.Fatalf("both succeeded and failed items must ride on stdout, got %d items\nstdout: %s", len(items), stdout.String())
}
}

func TestOutPartialFailureRawPreservesDocumentMarkup(t *testing.T) {
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+script"}, cfg, f, core.AsUser)

err := rt.OutPartialFailureRaw(map[string]interface{}{
"warning": []string{`use <img path="@relative/image.png"/>`},
}, nil)
var partialFailure *output.PartialFailureError
if !errors.As(err, &partialFailure) || partialFailure.Code != output.ExitAPI {
t.Fatalf("error = %T %v, want ExitAPI partial failure", err, err)
}
if bytes.Contains(stdout.Bytes(), []byte(`\u003cimg`)) || !bytes.Contains(stdout.Bytes(), []byte(`<img`)) {
t.Fatalf("raw partial-failure output escaped document markup: %s", stdout)
}
var envelope struct {
OK bool `json:"ok"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout)
}
if envelope.OK {
t.Fatalf("raw partial-failure output reported ok:true: %s", stdout)
}
}
12 changes: 9 additions & 3 deletions shortcuts/common/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,15 @@ type Shortcut struct {
// used to satisfy a Cobra Required flag; alternatives such as "A or legacy B"
// are a business constraint and must be validated as such.
Normalize FlagNormalizer
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
// Local returns true when the current flag combination does not require a
// Lark account or OpenAPI client. The framework then skips Lark
// identity/config, scope, and SDK initialization while preserving normal
// input resolution, validation, dry-run, and output behavior. A local
// operation may still access an explicitly supplied external resource.
Local func(cmd *cobra.Command) bool
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic

// OnInvoke, when non-nil, runs from the command's cobra PreRunE — before
// cobra validates required flags — so its side effect fires even when the
Expand Down
23 changes: 17 additions & 6 deletions shortcuts/doc/doc_media_insert.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"path/filepath"
"strings"

_ "golang.org/x/image/webp"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
Expand Down Expand Up @@ -531,7 +533,7 @@
case "docx":
return docRef.Token, nil
case "doc":
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "this document operation only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")

Check warning on line 536 in shortcuts/doc/doc_media_insert.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/doc_media_insert.go#L536

Added line #L536 was not covered by tests
case "wiki":
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token))
data, err := runtime.CallAPITyped(
Expand All @@ -551,13 +553,13 @@
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if objType != "docx" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but docs +media-insert only supports docx documents", objType).WithParam("--doc")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but this document operation only supports docx documents", objType).WithParam("--doc")

Check warning on line 556 in shortcuts/doc/doc_media_insert.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/doc_media_insert.go#L556

Added line #L556 was not covered by tests
}

fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to docx: %s\n", common.MaskToken(objToken))
return objToken, nil
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents").WithParam("--doc")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "this document operation only supports docx documents").WithParam("--doc")

Check warning on line 562 in shortcuts/doc/doc_media_insert.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/doc_media_insert.go#L562

Added line #L562 was not covered by tests
}
}

Expand Down Expand Up @@ -594,15 +596,24 @@
}

func detectImageDimensionsFromPath(fio fileio.FileIO, filePath string) (int, int, error) {
width, height, _, err := detectImageConfigFromPath(fio, filePath)
return width, height, err
}

func detectImageConfigFromPath(fio fileio.FileIO, filePath string) (int, int, string, error) {
if _, err := validate.SafeInputPath(filePath); err != nil {
return 0, 0, err
return 0, 0, "", err

Check warning on line 605 in shortcuts/doc/doc_media_insert.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/doc_media_insert.go#L605

Added line #L605 was not covered by tests
}
f, err := fio.Open(filePath)
if err != nil {
return 0, 0, err
return 0, 0, "", err

Check warning on line 609 in shortcuts/doc/doc_media_insert.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/doc_media_insert.go#L609

Added line #L609 was not covered by tests
}
defer f.Close()
return detectImageDimensions(f)
cfg, format, err := image.DecodeConfig(f)
if err != nil {
return 0, 0, "", err
}
return cfg.Width, cfg.Height, format, nil
}

func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption string, width, height int) map[string]interface{} {
Expand Down
Loading
Loading