Compare commits

...

7 Commits

Author SHA1 Message Date
heshuaichen
e896fe2ad5 fix: clarify --profile global flag help description 2026-07-02 14:04:15 +08:00
ILUO
e753b15d84 fix: expose completion state in my tasks output (#1641)
* fix: expose completion state in my tasks output

* test: cover my tasks pretty completion state
2026-07-01 15:41:57 +08:00
dc-bytedance
bdffffb368 feat: interactive upgrade prompt for bare lark-cli (#1498) 2026-07-01 15:07:18 +08:00
dc-bytedance
ec6fdc9b30 feat: fail closed when checksums.txt is missing during install (#1503) 2026-07-01 13:23:23 +08:00
liangshuo-1
775ee5a501 chore: release v1.0.61 (#1695) 2026-06-30 22:18:39 +08:00
liujinkun2025
214318aa02 fix: support bot identity for drive search (#1670) 2026-06-30 21:59:51 +08:00
liangshuo-1
6f2cddfce1 fix(identity): correct identity diagnosis under external credential providers (#1693) 2026-06-30 21:56:03 +08:00
25 changed files with 1168 additions and 160 deletions

View File

@@ -2,6 +2,24 @@
All notable changes to this project will be documented in this file.
## [v1.0.61] - 2026-06-30
### Features
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
- **identity**: Add `whoami` command showing effective identity (#1666)
- **docs**: Add reference map flags (#1547)
### Bug Fixes
- **identity**: Correct identity diagnosis under external credential providers (#1693)
- **cli**: Harden git credential error handling (#1676)
### Documentation
- **doc**: Guide document copy skill usage (#1673)
- **doc**: Fix lark-doc media token examples (#1662)
## [v1.0.60] - 2026-06-29
### Features
@@ -1299,6 +1317,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58

View File

@@ -214,6 +214,9 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
// before printing help; non-bare invocations and non-TTY are unaffected.
installRootUpgradePrompt(f, rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)

View File

@@ -129,7 +129,10 @@ func doctorRun(opts *DoctorOptions) error {
if diagnostics.Bot.Available || diagnostics.User.Available {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
// No hint: this only summarizes the two checks above, which already carry
// the source-appropriate remediation. A command here would be redundant,
// or wrong (`auth status` is blocked under an external provider).
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
// ── 4 & 5. Endpoint reachability ──

View File

@@ -4,14 +4,19 @@
package doctor
import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/spf13/cobra"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
@@ -140,14 +145,84 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
}
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
t.Helper()
if got := findCheck(t, checks, name); got.Status != status {
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
}
}
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
t.Helper()
for _, check := range checks {
if check.Name == name {
if check.Status != status {
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
}
return
return check
}
}
t.Fatalf("check %q not found in %#v", name, checks)
return checkResult{}
}
type fakeExtProvider struct {
name string
account *extcred.Account
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
// Under an external credential provider with no usable identity, the
// identity_ready hint must not point at `auth status` (blocked there); the
// per-identity checks already carry the source-appropriate escalation.
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
// Provider serves neither identity: bot unsupported, user supported but not
// signed in → both unavailable → identity_ready fails.
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
}
ready := findCheck(t, got.Checks, "identity_ready")
if ready.Status != "fail" {
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
}
// The summary defers to the per-identity checks; it carries no hint of its
// own (a command here would be wrong under an external provider).
if ready.Hint != "" {
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
}
user := findCheck(t, got.Checks, "user_identity")
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
}
}

View File

@@ -21,7 +21,7 @@ type GlobalOptions struct {
// applies any visibility policy encoded in opts. Pure function: no disk,
// network, or environment reads — the caller decides HideProfile.
func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
fs.StringVar(&opts.Profile, "profile", "", "use a specific profile")
fs.StringVar(&opts.Profile, "profile", "", "use a specific configuration profile (see 'lark-cli profile list')")
if opts.HideProfile {
_ = fs.MarkHidden("profile")
}

90
cmd/root_upgrade.go Normal file
View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bufio"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
// runRootUpgrade locates the registered `update` subcommand and runs it, so the
// interactive root-command upgrade reuses exactly `lark-cli update` behavior
// (install-method detection, output, error handling). Package-level var so
// tests can stub it and avoid real network / self-update.
var runRootUpgrade = func(cmd *cobra.Command) {
for _, c := range cmd.Root().Commands() {
if c.Name() == "update" && c.RunE != nil {
_ = c.RunE(c, nil) // update prints its own output/errors; swallow here
return
}
}
}
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
// no flags) — the only invocation that triggers the interactive upgrade prompt.
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
// AND no flag tokens in the raw invocation.
func isBareRootInvocation(args []string) bool {
return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0
}
// readYes reads one line and reports whether it is an affirmative y/yes.
// EOF / empty / anything else → false (default No, matching the [y/N] prompt).
func readYes(r io.Reader) bool {
line, _ := bufio.NewReader(r).ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true
default:
return false
}
}
// offerRootUpgrade prompts for an interactive upgrade when running bare
// `lark-cli` in an interactive terminal with a cached newer version. Every
// failure is swallowed — it must never affect help output or the exit code.
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
ios := f.IOStreams
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
// stdout TTY too so this only fires in a pure foreground terminal session.
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
return
}
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
// and the IsNewer/semver validation chain; it reads the on-disk cache that
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
info := update.CheckCached(build.Version)
if info == nil {
return
}
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
if !readYes(ios.In) {
return
}
runRootUpgrade(cmd)
}
// installRootUpgradePrompt wraps the root command's RunE (set to
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
// invocation offers an interactive upgrade before printing help. Non-bare
// invocations are passed straight through, unchanged.
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
inner := root.RunE
if inner == nil {
return
}
root.RunE = func(cmd *cobra.Command, args []string) error {
if isBareRootInvocation(args) {
offerRootUpgrade(f, cmd)
}
return inner(cmd, args)
}
}

191
cmd/root_upgrade_test.go Normal file
View File

@@ -0,0 +1,191 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
func writeUpdateState(t *testing.T, dir, latest string) {
t.Helper()
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}
func TestReadYes(t *testing.T) {
cases := map[string]bool{
"y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true,
"n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false,
}
for in, want := range cases {
if got := readYes(strings.NewReader(in)); got != want {
t.Errorf("readYes(%q) = %v, want %v", in, got, want)
}
}
}
func TestIsBareRootInvocation(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
rawInvocationArgs = nil
if !isBareRootInvocation([]string{}) {
t.Error("empty args + no raw flag tokens should be bare")
}
rawInvocationArgs = []string{"--profile", "x"}
if isBareRootInvocation([]string{}) {
t.Error("flag token present → not bare")
}
rawInvocationArgs = nil
if isBareRootInvocation([]string{"im"}) {
t.Error("positional arg → not bare")
}
}
func TestOfferRootUpgrade(t *testing.T) {
origV := build.Version
build.Version = "1.0.0" // release version so shouldSkip()==false
t.Cleanup(func() { build.Version = origV })
origRun := runRootUpgrade
t.Cleanup(func() { runRootUpgrade = origRun })
// This test builds a Factory literal (no NewDefault), so it never runs
// workspace detection; pin the process-global workspace to Local so
// statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale
// subdir inherited from a prior test in the package.
origWS := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
core.SetCurrentWorkspace(core.WorkspaceLocal)
cases := []struct {
name string
in, out, err bool
input string
latest string // "" → no state file (CheckCached nil)
optOut bool
wantPrompt, wantRun bool
}{
{"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true},
{"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true},
{"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false},
{"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false},
{"all-tty+eof", true, true, true, "", "2.0.0", false, true, false},
{"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false},
{"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false},
{"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false},
{"no-newer-version", true, true, true, "y\n", "", false, false, false},
{"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt
{"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false},
{"opt-out", true, true, true, "y\n", "2.0.0", true, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Clear env that update.shouldSkip treats as "suppress" so the
// test is deterministic regardless of host (GitHub Actions sets
// CI=true, which would otherwise suppress the prompt).
t.Setenv("CI", "")
t.Setenv("BUILD_NUMBER", "")
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
if tc.latest != "" {
writeUpdateState(t, dir, tc.latest)
}
if tc.optOut {
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
}
called := false
runRootUpgrade = func(*cobra.Command) { called = true }
var errBuf bytes.Buffer
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(tc.input),
Out: &bytes.Buffer{},
ErrOut: &errBuf,
IsTerminal: tc.in,
OutIsTerminal: tc.out,
StderrIsTerminal: tc.err,
}}
offerRootUpgrade(f, &cobra.Command{})
gotPrompt := strings.Contains(errBuf.String(), "available")
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}
})
}
}
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
rawInvocationArgs = nil
innerCalls := 0
root := &cobra.Command{Use: "lark-cli"}
root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil }
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
if err := root.RunE(root, []string{}); err != nil {
t.Fatalf("bare RunE err = %v", err)
}
if err := root.RunE(root, []string{"im"}); err != nil {
t.Fatalf("non-bare RunE err = %v", err)
}
if innerCalls != 2 {
t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls)
}
}
// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch
// path (not the stub used elsewhere): from any command it must locate the
// registered "update" subcommand via cmd.Root() and invoke its RunE.
func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
ran := 0
root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }})
child := &cobra.Command{Use: "im"}
root.AddCommand(child)
runRootUpgrade(child) // child.Root() resolves to root, which has "update"
if ran != 1 {
t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran)
}
}
// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard:
// when root has no RunE, installRootUpgradePrompt must not wrap it.
func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"} // RunE is nil
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
if root.RunE != nil {
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
}
}

View File

@@ -5,8 +5,6 @@ package whoami
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
@@ -17,6 +15,13 @@ import (
)
// whoamiResult is the structured output of `lark-cli whoami`.
//
// The self-vs-delegated distinction is carried by `identity`: a bot identity is
// the app acting as itself; a user identity is the app acting *on behalf of* a
// person (calls are attributed to that user, who is not necessarily present).
// onBehalfOf only *names* that person and so appears only once a user is
// resolved — a user identity that is not signed in still has identity "user"
// but no onBehalfOf yet. Do not read "no onBehalfOf" as "self"; read `identity`.
type whoamiResult struct {
Profile string `json:"profile"`
AppID string `json:"appId"`
@@ -26,34 +31,44 @@ type whoamiResult struct {
IdentitySource string `json:"identitySource"`
Available bool `json:"available"`
TokenStatus string `json:"tokenStatus"`
OpenID string `json:"openId,omitempty"`
UserName string `json:"userName,omitempty"`
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
Hint string `json:"hint,omitempty"`
}
// delegatedUser is the user a user-identity acts on behalf of.
type delegatedUser struct {
UserName string `json:"userName,omitempty"`
OpenID string `json:"openId,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.
// together with the active profile, app, and token status. Output is always
// JSON — whoami is consumed by agents. With the built-in credential path it is
// local-only; when an external credential provider manages tokens, resolving
// the identity may contact that provider.
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",
Short: "Show the current effective identity, app, profile, and token status (JSON)",
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")
// Output is always JSON. Accept (and ignore) --json so existing
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
// mode exists.
cmd.Flags().Bool("json", true, "deprecated: output is always JSON")
_ = cmd.Flags().MarkHidden("json")
cmdutil.SetRisk(cmd, "read")
return cmd
}
@@ -67,10 +82,11 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
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.
// Validate as a real API call does (strict mode, then identity) so whoami
// can't preview an identity the next call would refuse.
if err := f.CheckStrictMode(ctx, as); err != nil {
return err
}
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
return err
}
@@ -82,11 +98,7 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
)
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)
output.PrintJson(f.IOStreams.Out, res)
return nil
}
@@ -94,17 +106,18 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
// 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.
// Values are snake_case to match the other enum fields (e.g. tokenStatus).
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"
return "auto_detect"
}
if strictForced != "" {
return "strict-mode"
return "strict_mode"
}
return "default-as"
return "default_as"
}
// buildResult maps the resolved identity and local diagnostics into the output.
@@ -122,46 +135,29 @@ func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag iden
Identity: string(as),
IdentitySource: source,
}
// Use the diagnosed hint as-is: it is tailored to the credential source, so
// it never says "auth login" when that is blocked under an external provider.
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`)."
res.Hint = diag.Bot.Hint
}
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"
// Use Status (not the raw TokenStatus) so the vocab matches the bot
// branch: "ready" means usable for both. available stays the canonical
// usable signal; tokenStatus is the readable state behind it.
res.TokenStatus = diag.User.Status
// Set onBehalfOf only when a user is actually resolved; an unresolved
// user identity (not signed in) has no one to act on behalf of yet.
if diag.User.UserName != "" || diag.User.OpenID != "" {
res.OnBehalfOf = &delegatedUser{UserName: diag.User.UserName, OpenID: diag.User.OpenID}
}
if !diag.User.Available {
res.Hint = "No usable user token. Run `lark-cli auth login`."
res.Hint = diag.User.Hint
}
}
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)
}

View File

@@ -5,15 +5,19 @@ package whoami
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
)
@@ -28,10 +32,10 @@ func TestResolveSource(t *testing.T) {
}{
{"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"},
{"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) {
@@ -46,18 +50,19 @@ func TestResolveSource(t *testing.T) {
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"},
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
}
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Identity != "user" || r.IdentitySource != "auto-detect" {
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
}
if !r.Available || r.TokenStatus != "valid" {
// tokenStatus mirrors the unified Status vocab ("ready"), not the raw "valid".
if !r.Available || r.TokenStatus != "ready" {
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.OnBehalfOf == nil || r.OnBehalfOf.OpenID != "ou_x" || r.OnBehalfOf.UserName != "Alice" {
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x", r.OnBehalfOf)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
@@ -70,9 +75,9 @@ func TestBuildResult_UserValid(t *testing.T) {
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
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
}
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
@@ -80,8 +85,10 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
if r.TokenStatus != "missing" {
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
}
if r.Hint == "" {
t.Fatalf("hint empty, want guidance")
// whoami renders the diagnosed hint verbatim (single source of truth) so it
// stays correct for the external-provider path without whoami knowing about it.
if r.Hint != diag.User.Hint {
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.User.Hint)
}
if r.DefaultAs != "auto" {
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
@@ -93,16 +100,16 @@ func TestBuildResult_BotReady(t *testing.T) {
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: true, Status: "ready"},
}
r := buildResult(cfg, core.AsBot, "default-as", diag)
r := buildResult(cfg, core.AsBot, "default_as", diag)
if r.Identity != "bot" || r.IdentitySource != "default-as" {
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.OnBehalfOf != nil {
t.Fatalf("bot must not carry onBehalfOf: %#v", r.OnBehalfOf)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
@@ -112,9 +119,9 @@ func TestBuildResult_BotReady(t *testing.T) {
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"},
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
}
r := buildResult(cfg, core.AsBot, "auto-detect", diag)
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
@@ -122,58 +129,8 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
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)
if r.Hint != diag.Bot.Hint {
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint)
}
}
@@ -183,7 +140,7 @@ func TestWhoami_BotJSON(t *testing.T) {
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--json"})
cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
@@ -204,8 +161,8 @@ func TestWhoami_BotJSON(t *testing.T) {
if got.IdentitySource == "" {
t.Fatalf("identitySource empty")
}
if got.OpenID != "" {
t.Fatalf("bot must not carry openId: %q", got.OpenID)
if got.OnBehalfOf != nil {
t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf)
}
}
@@ -256,3 +213,108 @@ func TestWhoami_ConfigErrorPropagates(t *testing.T) {
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
}
}
func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) {
// Bot-only account → strict mode bot. A real `--as user` call would be
// rejected by CheckStrictMode; whoami must reject it identically rather than
// previewing a user identity the next call would refuse.
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
SupportedIdentities: 2, // bot only
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() with --as user under strict bot = nil, want strict-mode rejection")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError: %v", err, err)
}
}
type fakeExtProvider struct {
name string
account *extcred.Account
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil // no UAT served locally; whoami runs with verify=false
}
func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) {
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}},
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
return f, out
}
// Regression for the external-provider blind spot: with credentials managed by
// an extension provider, a signed-in user must read as available, and an
// unavailable identity must not be told to "auth login" (which is blocked).
func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice",
}
f, out := externalWhoamiFactory(cfg)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
}
if got.Identity != "user" || !got.Available || got.TokenStatus != "ready" {
t.Fatalf("got %#v, want user/available/ready", got)
}
if got.OnBehalfOf == nil || got.OnBehalfOf.UserName != "Alice" || got.OnBehalfOf.OpenID != "ou_x" {
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x (delegated)", got.OnBehalfOf)
}
if got.Hint != "" {
t.Fatalf("hint = %q, want empty when available", got.Hint)
}
}
func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in
}
f, out := externalWhoamiFactory(cfg)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
}
if got.Identity != "user" || got.Available {
t.Fatalf("got identity=%q available=%v, want user/false", got.Identity, got.Available)
}
if strings.Contains(got.Hint, "auth login") {
t.Fatalf("hint must not point at auth login under external provider: %q", got.Hint)
}
if !strings.Contains(got.Hint, "external") {
t.Fatalf("hint should explain external management: %q", got.Hint)
}
}

View File

@@ -18,6 +18,9 @@ type IOStreams struct {
Out io.Writer
ErrOut io.Writer
IsTerminal bool
// OutIsTerminal reports whether Out is an interactive terminal. Mirrors
// IsTerminal; computed once in NewIOStreams and assignable directly in tests.
OutIsTerminal bool
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
// so they stay out of non-interactive output (pipes, CI, agent runs).
@@ -27,19 +30,24 @@ type IOStreams struct {
}
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
// false.
// IsTerminal / OutIsTerminal / StderrIsTerminal are each derived from the
// underlying *os.File of in / out / errOut respectively; non-file
// readers/writers (bytes.Buffer, strings.Reader, …) yield false.
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
fileIsTerminal := func(v any) bool {
if f, ok := v.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}
stderrIsTerminal := false
if f, ok := errOut.(*os.File); ok {
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
return &IOStreams{
In: in,
Out: out,
ErrOut: errOut,
IsTerminal: fileIsTerminal(in),
OutIsTerminal: fileIsTerminal(out),
StderrIsTerminal: fileIsTerminal(errOut),
}
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"os"
"testing"
)
func TestNewIOStreamsTerminalFlagsNonFile(t *testing.T) {
s := NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
if s.IsTerminal || s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("non-file streams must not be terminals: in=%v out=%v err=%v",
s.IsTerminal, s.OutIsTerminal, s.StderrIsTerminal)
}
}
func TestNewIOStreamsTerminalFlagsPipe(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
s := NewIOStreams(r, w, w)
if s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("os.Pipe must not be a terminal: out=%v err=%v", s.OutIsTerminal, s.StderrIsTerminal)
}
}

View File

@@ -13,6 +13,7 @@ import (
"strings"
"time"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -61,12 +62,131 @@ func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, veri
if ctx == nil {
ctx = context.Background()
}
// An external provider mints tokens on demand and blocks interactive auth,
// so the built-in keychain heuristics and "auth login" hints don't apply.
if provider := activeExternalProvider(ctx, f); provider != "" {
return diagnoseExternal(ctx, f, cfg, provider, verify)
}
return Result{
Bot: diagnoseBot(ctx, f, cfg, verify),
User: diagnoseUser(ctx, f, cfg, verify),
}
}
// activeExternalProvider returns the active extension provider name, or "".
// An error degrades to the built-in path: an unreachable provider would already
// have failed the f.Config() that produced cfg.
func activeExternalProvider(ctx context.Context, f *cmdutil.Factory) string {
if f == nil || f.Credential == nil {
return ""
}
name, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return ""
}
return name
}
func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, verify bool) Result {
if cfg == nil || cfg.AppID == "" {
notConfigured := Identity{
Status: StatusNotConfigured,
Message: "not configured (missing app config)",
Hint: externalCredentialHint(provider),
}
return Result{Bot: notConfigured, User: notConfigured}
}
// SupportedIdentities == 0 is "unspecified" — treat as both, per CanBot.
ids := extcred.IdentitySupport(cfg.SupportedIdentities)
supportsBot := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsBot)
supportsUser := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsUser)
return Result{
Bot: diagnoseExternalBot(ctx, f, cfg, provider, supportsBot, verify),
User: diagnoseExternalUser(ctx, f, cfg, provider, supportsUser, verify),
}
}
func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
if !supported {
return notProvidedExternally("Bot", provider)
}
id := Identity{Status: StatusReady, Available: true, Message: "Bot identity: ready (provided by " + provider + ")"}
if !verify {
return id
}
token, err := resolveBotToken(ctx, f, cfg)
if err != nil {
return externalVerifyFailed(id, "Bot", provider, err)
}
info, err := fetchBotInfo(ctx, f, cfg, token)
if err != nil {
return externalVerifyFailed(id, "Bot", provider, err)
}
id.Verified = boolPtr(true)
id.OpenID = info.OpenID
id.AppName = info.AppName
return id
}
func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
if !supported {
return notProvidedExternally("User", provider)
}
// enrichUserInfo populates UserOpenId only after the provider returns and
// verifies a UAT (and clears it on failure), so a resolved open id is the
// external analogue of a keychain token being present.
if cfg.UserOpenId == "" {
return Identity{
Status: StatusMissing,
Message: "User identity: not signed in via credential source " + provider,
Hint: externalCredentialHint(provider),
}
}
id := Identity{
Status: StatusReady,
Available: true,
TokenStatus: StatusReady,
UserName: cfg.UserName,
OpenID: cfg.UserOpenId,
Message: "User identity: ready (provided by " + provider + ")",
}
if !verify {
return id
}
if _, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsUser, cfg.AppID)); err != nil {
return externalVerifyFailed(id, "User", provider, err)
}
id.Verified = boolPtr(true)
return id
}
func notProvidedExternally(label, provider string) Identity {
return Identity{
Status: StatusNotConfigured,
Message: label + " identity: not provided by credential source " + provider,
Hint: externalCredentialHint(provider),
}
}
// externalVerifyFailed flips id to verify-failed, keeping any identity fields
// (open id, user name) already resolved before the probe.
func externalVerifyFailed(id Identity, label, provider string, err error) Identity {
id.Available = false
id.Verified = boolPtr(false)
id.Status = StatusVerifyFailed
id.TokenStatus = ""
id.Message = label + " identity: verify failed: " + err.Error()
id.Hint = externalCredentialHint(provider)
return id
}
// externalCredentialHint reports the constraint, not a remediation: the
// identity is the provider's to manage, not lark-cli's to fix. What to do about
// it is the caller's call — there may be no user to ask.
func externalCredentialHint(provider string) string {
return fmt.Sprintf("managed by the external credential provider %q and cannot be configured via lark-cli", provider)
}
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
if cfg == nil || cfg.AppID == "" {
return Identity{

View File

@@ -10,9 +10,11 @@ import (
"testing"
"time"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
@@ -348,3 +350,136 @@ func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
}
}
// fakeExtProvider is a minimal credential.extcred.Provider for exercising the
// external-credential diagnosis path. account makes the provider "active";
// token (when set) satisfies ResolveToken during verify.
type fakeExtProvider struct {
name string
account *extcred.Account
token *extcred.Token
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return p.token, nil
}
func externalFactory(prov *fakeExtProvider, cfg *core.CliConfig) *cmdutil.Factory {
cred := credential.NewCredentialProvider(
[]extcred.Provider{prov}, nil, nil,
func() (*http.Client, error) { return nil, nil },
)
return &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{},
}
}
// assertExternalHint locks the contract that an external-provider hint never
// points at interactive commands blocked under an external provider.
func assertExternalHint(t *testing.T, hint string) {
t.Helper()
if hint == "" {
t.Fatalf("hint empty, want external guidance")
}
for _, blocked := range []string{"auth login", "config --help"} {
if strings.Contains(hint, blocked) {
t.Fatalf("hint %q must not point at %q (blocked under external provider)", hint, blocked)
}
}
if !strings.Contains(hint, "external") {
t.Fatalf("hint %q should explain credentials are external", hint)
}
}
func TestDiagnose_External_UserReady(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
// The bug this guards: the built-in path read the keychain (empty under an
// external provider) and reported the user as missing. Now availability
// follows the resolved account, so a signed-in user reads as ready.
if !got.User.Available || got.User.Status != StatusReady || got.User.TokenStatus != StatusReady {
t.Fatalf("user = %#v, want ready/available", got.User)
}
if got.User.OpenID != "ou_x" || got.User.UserName != "Alice" {
t.Fatalf("user identity = %#v", got.User)
}
if got.User.Hint != "" {
t.Fatalf("hint = %q, want empty when available", got.User.Hint)
}
if !got.Bot.Available || got.Bot.Status != StatusReady {
t.Fatalf("bot = %#v, want ready/available", got.Bot)
}
}
func TestDiagnose_External_UserNotSignedIn(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll)}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if got.User.Available || got.User.Status != StatusMissing {
t.Fatalf("user = %#v, want missing/unavailable", got.User)
}
assertExternalHint(t, got.User.Hint)
}
func TestDiagnose_External_BotOnly(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsBot), UserOpenId: "ou_x"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if !got.Bot.Available || got.Bot.Status != StatusReady {
t.Fatalf("bot = %#v, want ready/available", got.Bot)
}
// Provider declares bot-only: user is unavailable even though an open id is
// present, and the hint is external (not "auth login").
if got.User.Available || got.User.Status != StatusNotConfigured {
t.Fatalf("user = %#v, want not_configured/unavailable", got.User)
}
assertExternalHint(t, got.User.Hint)
}
func TestDiagnose_External_UserOnly(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandLark, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Bob"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if !got.User.Available || got.User.Status != StatusReady {
t.Fatalf("user = %#v, want ready/available", got.User)
}
if got.Bot.Available || got.Bot.Status != StatusNotConfigured {
t.Fatalf("bot = %#v, want not_configured/unavailable", got.Bot)
}
assertExternalHint(t, got.Bot.Hint)
}
func TestDiagnose_External_VerifyUserResolvesToken(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Alice"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}, token: &extcred.Token{Value: "ext-uat"}}, cfg)
got := Diagnose(context.Background(), f, cfg, true)
if !got.User.Available || got.User.Verified == nil || !*got.User.Verified {
t.Fatalf("user = %#v, want available and verified", got.User)
}
}
func TestDiagnose_External_VerifyUserTokenUnavailable(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, true)
if got.User.Available || got.User.Status != StatusVerifyFailed {
t.Fatalf("user = %#v, want verify_failed/unavailable", got.User)
}
if got.User.Verified == nil || *got.User.Verified {
t.Fatalf("verified = %v, want false", got.User.Verified)
}
assertExternalHint(t, got.User.Hint)
}

View File

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

View File

@@ -265,10 +265,9 @@ function getExpectedChecksum(archiveName, checksumsDir) {
const checksumsPath = path.join(dir, "checksums.txt");
if (!fs.existsSync(checksumsPath)) {
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
throw new Error(
"[SECURITY] checksums.txt not found; refusing to install an unverified binary."
);
return null;
}
const content = fs.readFileSync(checksumsPath, "utf8");
@@ -286,7 +285,11 @@ function getExpectedChecksum(archiveName, checksumsDir) {
}
function verifyChecksum(archivePath, expectedHash) {
if (expectedHash === null) return;
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
throw new Error(
"[SECURITY] missing expected checksum; refusing to install an unverified binary."
);
}
// Stream the file to avoid loading the entire archive into memory.
// Archives can be 10-100MB; streaming keeps RSS constant.

View File

@@ -52,11 +52,17 @@ describe("getExpectedChecksum", () => {
);
});
it("returns null when checksums.txt does not exist", () => {
it("throws [SECURITY] when checksums.txt does not exist (fail-closed)", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
// No checksums.txt in dir
const result = getExpectedChecksum("anything.tar.gz", dir);
assert.equal(result, null);
assert.throws(
() => getExpectedChecksum("anything.tar.gz", dir),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
assert.match(err.message, /checksums\.txt not found/);
return true;
}
);
});
it("skips malformed lines and still finds valid entry", () => {
@@ -125,6 +131,19 @@ describe("verifyChecksum", () => {
}
);
});
it("verifyChecksum throws [SECURITY] on null/empty expectedHash (fail-closed)", () => {
const filePath = makeTmpFile("content");
for (const expectedHash of [null, ""]) {
assert.throws(
() => verifyChecksum(filePath, expectedHash),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
return true;
}
);
}
});
});
describe("assertAllowedHost", () => {

View File

@@ -72,7 +72,7 @@ var DriveSearch = common.Shortcut{
Description: "Search Lark docs, Wiki, and spreadsheet files with flat filters (Search v2: doc_wiki/search)",
Risk: "read",
Scopes: []string{"search:docs:read"},
AuthTypes: []string{"user"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},

View File

@@ -3,7 +3,10 @@
package drive
import "testing"
import (
"reflect"
"testing"
)
// TestShortcutsIncludesExpectedCommands verifies the drive shortcut registry contains the expected commands.
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
@@ -58,3 +61,12 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
}
}
}
func TestDriveSearchSupportsUserAndBotIdentity(t *testing.T) {
t.Parallel()
want := []string{"user", "bot"}
if !reflect.DeepEqual(DriveSearch.AuthTypes, want) {
t.Fatalf("DriveSearch.AuthTypes = %v, want %v", DriveSearch.AuthTypes, want)
}
}

View File

@@ -200,16 +200,21 @@ var GetMyTasks = common.Shortcut{
for _, item := range filteredItems {
urlVal, _ := item["url"].(string)
urlVal = truncateTaskURL(urlVal)
completed, completedAt := taskCompletionState(item)
outputItem := map[string]interface{}{
"guid": item["guid"],
"summary": item["summary"],
"url": urlVal,
"guid": item["guid"],
"summary": item["summary"],
"url": urlVal,
"completed": completed,
}
if createdAtStr, ok := item["created_at"].(string); ok {
if ts, err := strconv.ParseInt(createdAtStr, 10, 64); err == nil {
outputItem["created_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339)
}
}
if !completedAt.IsZero() {
outputItem["completed_at"] = completedAt.Local().Format(time.RFC3339)
}
if dueObj, ok := item["due"].(map[string]interface{}); ok {
if tsStr, ok := dueObj["timestamp"].(string); ok {
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
@@ -237,6 +242,7 @@ var GetMyTasks = common.Shortcut{
summary, _ := item["summary"].(string)
urlVal, _ := item["url"].(string)
urlVal = truncateTaskURL(urlVal)
completed, completedAt := taskCompletionState(item)
var dueTimeStr string
if dueObj, ok := item["due"].(map[string]interface{}); ok {
@@ -259,6 +265,10 @@ var GetMyTasks = common.Shortcut{
if urlVal != "" {
fmt.Fprintf(w, " URL: %s\n", urlVal)
}
fmt.Fprintf(w, " Completed: %t\n", completed)
if !completedAt.IsZero() {
fmt.Fprintf(w, " Completed At: %s\n", completedAt.Local().Format("2006-01-02 15:04"))
}
if dueTimeStr != "" {
fmt.Fprintf(w, " Due: %s\n", dueTimeStr)
}
@@ -278,3 +288,15 @@ var GetMyTasks = common.Shortcut{
return nil
},
}
func taskCompletionState(item map[string]interface{}) (bool, time.Time) {
completedAtStr, _ := item["completed_at"].(string)
if completedAtStr == "" || completedAtStr == "0" {
return false, time.Time{}
}
ts, err := strconv.ParseInt(completedAtStr, 10, 64)
if err != nil {
return false, time.Time{}
}
return true, time.UnixMilli(ts)
}

View File

@@ -110,6 +110,118 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
}
}
func TestGetMyTasks_IncludesCompletionStateInJSON(t *testing.T) {
tsMs := int64(1775174400000)
tsStr := strconv.FormatInt(tsMs, 10)
expectedCompletedAt := time.UnixMilli(tsMs).Local().Format(time.RFC3339)
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"guid": "task-open",
"summary": "Open Task",
"completed_at": "0",
"url": "https://example.com/task-open",
},
map[string]interface{}{
"guid": "task-done",
"summary": "Done Task",
"completed_at": tsStr,
"url": "https://example.com/task-done",
},
},
"has_more": false,
"page_token": "",
},
},
})
s := GetMyTasks
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
outNorm := strings.ReplaceAll(stdout.String(), `":"`, `": "`)
for _, expected := range []string{
`"guid": "task-open"`,
`"completed": false`,
`"guid": "task-done"`,
`"completed": true`,
`"completed_at": "` + expectedCompletedAt + `"`,
} {
if !strings.Contains(outNorm, expected) {
t.Fatalf("output missing expected string (%s), got: %s", expected, stdout.String())
}
}
}
func TestGetMyTasks_IncludesCompletionStateInPretty(t *testing.T) {
tsMs := int64(1775174400000)
tsStr := strconv.FormatInt(tsMs, 10)
expectedCompletedAt := time.UnixMilli(tsMs).Local().Format("2006-01-02 15:04")
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"guid": "task-open",
"summary": "Open Task",
"completed_at": "0",
"url": "https://example.com/task-open",
},
map[string]interface{}{
"guid": "task-done",
"summary": "Done Task",
"completed_at": tsStr,
"url": "https://example.com/task-done",
},
},
"has_more": false,
"page_token": "",
},
},
})
s := GetMyTasks
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "pretty", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := stdout.String()
for _, expected := range []string{
"[1] Open Task\n ID: task-open\n URL: https://example.com/task-open\n Completed: false\n",
"[2] Done Task\n ID: task-done\n URL: https://example.com/task-done\n Completed: true\n Completed At: " + expectedCompletedAt + "\n",
} {
if !strings.Contains(out, expected) {
t.Fatalf("output missing expected string (%s), got: %s", expected, out)
}
}
if count := strings.Count(out, "Completed At:"); count != 1 {
t.Fatalf("Completed At count = %d, want 1; output: %s", count, out)
}
}
// TestGetMyTasks_InvalidTimeFlags locks the three time-flag validation arms in
// Execute (--created_at / --due-start / --due-end). The parse runs before any
// API call, so a malformed value deterministically surfaces a typed

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
基于 Search v2 接口 `POST /open-apis/search/v2/doc_wiki/search`,以**用户身份**统一搜索云空间(云盘/云存储)对象。
基于 Search v2 接口 `POST /open-apis/search/v2/doc_wiki/search`支持以**用户身份或应用身份**统一搜索云空间(云盘/云存储)对象。
核心特性:
@@ -14,6 +14,8 @@
> **资源发现入口统一**`drive +search` 同样返回 `SHEET` / `Base` / `FOLDER` 等全部云空间(云盘/云存储)对象,不只是文档 / Wiki。用户说"找一个表格"、"找报表"、"最近打开的表格"时,也从这里开始;定位后再切到对应业务 skill如 `lark-sheets`)做对象内部操作。
> **身份边界**普通关键词、类型、文件夹、Wiki 空间、owner/open_id 等显式过滤支持 `--as user` 或 `--as bot`。`--mine` / `--created-by-me` 依赖当前登录用户 open_id 自动填充过滤条件;应用身份下如果没有配置用户 open_id请改用显式 `--creator-ids` / `--original-creator-ids`。
## 命令
> **关键约束:搜索关键词必须通过 `--query` 传递。**

View File

@@ -33,7 +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 whoami`;实际生效的那一个身份 |
| 退出当前机器的用户登录态 | `lark-cli auth logout --json``loggedOut:true` 表示注销成功 |
| bot 缺少权限 | 不要执行 `auth login`;引导用户在开发者后台开通 bot scope优先复用错误里的 `console_url` |
| 取消用户对应用的全部服务端授权 | `auth logout` 只清本机登录态;服务端授权需用户在飞书授权管理页取消 |

View File

@@ -164,6 +164,45 @@ func TestDriveSearchDryRun_RequestShape(t *testing.T) {
}
}
func TestDriveSearchDryRun_BotIdentity(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "season report",
"--page-size", "5",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Args, "--as")
require.Contains(t, result.Args, "bot")
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s\nstderr:\n%s", got, out, result.Stderr)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/search/v2/doc_wiki/search" {
t.Fatalf("url=%q, want Search v2 doc_wiki/search\nstdout:\n%s\nstderr:\n%s", got, out, result.Stderr)
}
if got := gjson.Get(out, "api.0.body.query").String(); got != "season report" {
t.Fatalf("body.query=%q, want season report\nstdout:\n%s", got, out)
}
helpResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+search", "--help"},
})
require.NoError(t, err)
helpResult.AssertExitCode(t, 0)
require.Contains(t, helpResult.Stdout, "identity type: user | bot")
}
// TestDriveSearchDryRun_OpenedClamping locks in the agent-facing slice
// notice for --opened-* spans over 90 days: the request body must carry
// the most recent 90-day window, and stderr must list slice N's flag

View File

@@ -2,8 +2,8 @@
## Metrics
- Denominator: 29 leaf commands
- Covered: 14
- Coverage: 48.3%
- Covered: 15
- Coverage: 51.7%
## Summary
- TestTask_StatusWorkflow: creates a task via `task +create`, then proves `task +complete`, `task tasks get`, and `task +reopen` through `complete`, `get completed task`, `reopen`, and `get reopened task`; asserts `status` flips between `done` and `todo` and `completed_at` is set then cleared.
@@ -13,9 +13,10 @@
- TestTask_TasklistWorkflowAsBot: runs `create tasklist with task`, then `get tasklist`, `list tasklist tasks`, and `get task`; proves `task +tasklist-create`, `task tasklists get`, `task tasklists tasks`, and `task tasks get` with seeded task payload and task-to-tasklist linkage.
- TestTask_TasklistWorkflowAsUser: creates a tasklist as `--as user`, patches its name through `task tasklists patch`, then proves both `task tasklists get` and `task tasklists list` return the patched tasklist.
- TestTask_TasklistAddTaskWorkflow: creates a standalone tasklist and task, runs `add task to tasklist`, then `list tasklist tasks` and `get task with tasklist link`; proves `task +tasklist-task-add`, `task tasklists tasks`, and `task tasks get`, including no failed tasks in the add response.
- TestTask_GetMyTasksDryRun: validates `task +get-my-tasks --dry-run` request shape for `type=my_tasks`, `user_id_type=open_id`, `completed`, `page_token`, and default `page_size` without calling live APIs.
- Cleanup path note: workflow-created tasks and tasklists are deleted through direct `task tasks delete` / `task tasklists delete` cleanup paths in `helpers_test.go::createTask`, `helpers_test.go::createTasklist`, `tasklist_workflow_test.go::TestTask_TasklistWorkflowAsBot`, and `tasklist_workflow_test.go::TestTask_TasklistWorkflowAsUser`, but those cleanup-only executions are not counted as command coverage because no testcase asserts delete behavior as the primary proof surface.
- Blocked area: assignee, follower, and tasklist member mutations still require stable real-user `open_id` fixtures; the current suite is bot-safe only.
- Blocked area: `task +get-my-tasks` and `task tasks list` did not return the workflow-created user task deterministically in UAT, so they are left uncovered instead of being counted from flaky list visibility.
- Blocked area: `task +get-my-tasks` live result assertions and `task tasks list` did not return the workflow-created user task deterministically in UAT, so live list visibility remains uncovered instead of being counted from flaky results.
- Blocked area: the remaining user-oriented shortcuts still need deterministic user-owned fixtures or collaborator fixtures beyond the self-owned task created inside the testcase.
- Gap pattern: direct `tasks create/delete/list/patch`, `tasklists create/delete/list/patch`, `members *`, and `subtasks *` APIs still lack deterministic direct-call workflows, so shortcut coverage does not count for those leaf commands.
@@ -28,7 +29,7 @@
| ✓ | task +complete | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/complete | `--task-id` | |
| ✓ | task +create | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow; task_comment_workflow_test.go::TestTask_CommentWorkflow; task_reminder_workflow_test.go::TestTask_ReminderWorkflow; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow | `summary` + `description`; `due.timestamp` + `due.is_all_day` | |
| ✕ | task +followers | shortcut | | none | requires real follower open_id fixtures; shortcut defaults to `--as user` |
| | task +get-my-tasks | shortcut | | none | UAT did not return the workflow-created user task deterministically in my-tasks views |
| | task +get-my-tasks | shortcut | task_get_my_tasks_dryrun_test.go::TestTask_GetMyTasksDryRun | `--complete`; `--page-token`; dry-run only | live UAT did not return the workflow-created user task deterministically in my-tasks views |
| ✓ | task +reminder | shortcut | task_reminder_workflow_test.go::TestTask_ReminderWorkflow/set reminder; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/remove reminder | `--task-id --set 30m`; `--task-id --remove` | |
| ✓ | task +reopen | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/reopen | `--task-id` | |
| ✓ | task +tasklist-create | shortcut | tasklist_workflow_test.go::TestTask_TasklistWorkflowAsBot/create tasklist with task as bot; tasklist_workflow_test.go::TestTask_TasklistWorkflowAsUser/create tasklist as user; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow | `--name` only; `--name` plus task array in `--data` | |

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestTask_GetMyTasksDryRun validates the request shape emitted by
// task +get-my-tasks under --dry-run. Fake credentials are sufficient because
// dry-run stops before any network call.
func TestTask_GetMyTasksDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "task_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_dryrun_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{
"task", "+get-my-tasks",
"--complete",
"--page-token", "pt_001",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if count := gjson.Get(out, "api.#").Int(); count != 1 {
t.Fatalf("expected 1 API call, got %d\nstdout:\n%s", count, out)
}
if method := gjson.Get(out, "api.0.method").String(); method != "GET" {
t.Fatalf("api[0].method = %q, want GET\nstdout:\n%s", method, out)
}
if url := gjson.Get(out, "api.0.url").String(); url != "/open-apis/task/v2/tasks" {
t.Fatalf("api[0].url = %q, want /open-apis/task/v2/tasks\nstdout:\n%s", url, out)
}
if got := gjson.Get(out, "api.0.params.type").String(); got != "my_tasks" {
t.Fatalf("api[0].params.type = %q, want my_tasks\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.user_id_type").String(); got != "open_id" {
t.Fatalf("api[0].params.user_id_type = %q, want open_id\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.completed").Bool(); !got {
t.Fatalf("api[0].params.completed = %v, want true\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.page_token").String(); got != "pt_001" {
t.Fatalf("api[0].params.page_token = %q, want pt_001\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 50 {
t.Fatalf("api[0].params.page_size = %d, want 50\nstdout:\n%s", got, out)
}
}