diff --git a/README.md b/README.md index d6a0af876..b788474cd 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,15 @@ lark-cli schema calendar.events.instance_view lark-cli schema im.messages.delete ``` +### Command Preflight + +Use `doctor preflight` to check whether a shortcut is ready before executing it. This is especially useful for AI agents that need to know whether config, identity, login, scopes, or risk confirmations are still missing. + +```bash +lark-cli doctor preflight calendar +agenda +lark-cli doctor preflight im +messages-send --as bot +``` + ## Security & Risk Warnings (Read Before Use) This tool can be invoked by AI Agents to automate operations on the Lark/Feishu Open Platform, and carries inherent risks such as model hallucinations, unpredictable execution, and prompt injection. After you authorize Lark/Feishu permissions, the AI Agent will act under your user identity within the authorized scope, which may lead to high-risk consequences such as leakage of sensitive data or unauthorized operations. Please use with caution. diff --git a/README.zh.md b/README.zh.md index 4d8e573d8..671ddbe07 100644 --- a/README.zh.md +++ b/README.zh.md @@ -267,6 +267,15 @@ lark-cli schema calendar.events.instance_view lark-cli schema im.messages.delete ``` +### 命令前置体检 + +使用 `doctor preflight` 在真正执行 shortcut 前检查是否已具备配置、身份、登录、scope 与风险确认条件,尤其适合 AI agent 在执行前先判断 readiness。 + +```bash +lark-cli doctor preflight calendar +agenda +lark-cli doctor preflight im +messages-send --as bot +``` + ## 安全与风险提示(使用前必读) 本工具可供 AI Agent 调用以自动化操作飞书/Lark 开放平台,存在模型幻觉、执行不可控、提示词注入等固有风险;授权飞书权限后,AI Agent 将以您的用户身份在授权范围内执行操作,可能导致敏感数据泄露、越权操作等高风险后果,请您谨慎操作和使用。 diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index 0f569bab0..23cd428de 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -43,6 +43,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command { } cmdutil.DisableAuthCheck(cmd) cmd.Flags().BoolVar(&opts.Offline, "offline", false, "skip network checks (only verify local state)") + cmd.AddCommand(NewCmdDoctorPreflight(f)) return cmd } diff --git a/cmd/doctor/preflight.go b/cmd/doctor/preflight.go new file mode 100644 index 000000000..bd88deb42 --- /dev/null +++ b/cmd/doctor/preflight.go @@ -0,0 +1,729 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doctor + +import ( + "context" + "errors" + "fmt" + "io" + "slices" + "strings" + + "github.com/spf13/cobra" + + internalauth "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/output" + "github.com/larksuite/cli/shortcuts" + "github.com/larksuite/cli/shortcuts/common" +) + +var preflightGetStoredToken = internalauth.GetStoredToken +var preflightTokenStatus = internalauth.TokenStatus + +// DoctorPreflightOptions holds inputs for doctor preflight. +type DoctorPreflightOptions struct { + Factory *cmdutil.Factory + Ctx context.Context + Service string + Shortcut string + RequestedAs string + Format string +} + +type preflightTarget struct { + Service string `json:"service"` + Command string `json:"command"` + Risk string `json:"risk"` + AuthTypes []string `json:"auth_types"` + Scopes []string `json:"scopes"` +} + +type preflightIdentity struct { + Requested string `json:"requested"` + Resolved string `json:"resolved"` + Source string `json:"source"` +} + +type preflightCheck struct { + Name string `json:"name"` + Status string `json:"status"` + Blocking bool `json:"blocking"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` +} + +type preflightAction struct { + Type string `json:"type"` + Blocking bool `json:"blocking"` + Command string `json:"command,omitempty"` + Reason string `json:"reason"` +} + +type preflightExecutionFlag struct { + Name string `json:"name"` + Type string `json:"type"` + Required bool `json:"required"` + Default string `json:"default,omitempty"` + Enum []string `json:"enum,omitempty"` + Input []string `json:"input,omitempty"` + Description string `json:"description,omitempty"` +} + +type preflightExecution struct { + Command string `json:"command"` + DryRunCommand string `json:"dry_run_command,omitempty"` + SupportsDryRun bool `json:"supports_dry_run"` + RequiresConfirmation bool `json:"requires_confirmation"` + Flags []preflightExecutionFlag `json:"flags,omitempty"` + Notes []string `json:"notes,omitempty"` +} + +type preflightResult struct { + OK bool `json:"ok"` + Ready bool `json:"ready"` + Workspace string `json:"workspace"` + Target preflightTarget `json:"target"` + Identity preflightIdentity `json:"identity"` + Execution preflightExecution `json:"execution"` + Checks []preflightCheck `json:"checks"` + NextActions []preflightAction `json:"next_actions,omitempty"` + Notice map[string]any `json:"_notice,omitempty"` +} + +func NewCmdDoctorPreflight(f *cmdutil.Factory) *cobra.Command { + opts := &DoctorPreflightOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "preflight ", + Short: "Check whether a shortcut is ready before execution", + Long: `Check whether a shortcut is ready before execution. + +This command does not execute the target shortcut. It evaluates config, +identity, strict mode, login, scope readiness, and risk hints so users +and AI agents can decide the next action before running the real command.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Ctx = cmd.Context() + opts.Service = args[0] + opts.Shortcut = args[1] + if !slices.Contains([]string{"json", "pretty"}, opts.Format) { + return output.ErrValidation("invalid --format %q: must be json or pretty", opts.Format) + } + return doctorPreflightRun(opts) + }, + } + cmd.Flags().StringVar(&opts.RequestedAs, "as", "auto", "identity to evaluate (auto|user|bot)") + cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") + return cmd +} + +func doctorPreflightRun(opts *DoctorPreflightOptions) error { + shortcut, err := resolveTargetShortcut(opts.Service, opts.Shortcut) + if err != nil { + return err + } + + target := preflightTarget{ + Service: shortcut.Service, + Command: shortcut.Command, + Risk: shortcutRisk(shortcut), + AuthTypes: shortcutAuthTypes(shortcut), + } + identity := preflightIdentity{ + Requested: normalizedRequestedIdentity(opts.RequestedAs), + } + + cfg, err := resolvePreflightConfig(opts.Factory) + if err != nil { + result := buildConfigFailureResult(target, identity, err) + writePreflightResult(opts.Factory.IOStreams.Out, result, opts.Format) + return output.ErrBare(1) + } + + resolvedAs, source, err := resolvePreflightIdentity(opts, cfg) + if err != nil { + return err + } + target.Scopes = shortcut.ScopesForIdentity(string(resolvedAs)) + identity.Resolved = string(resolvedAs) + identity.Source = source + + execution := buildPreflightExecution(shortcut, resolvedAs) + checks, actions := runPreflightChecks(opts, cfg, shortcut, resolvedAs, target.Scopes) + ready := isPreflightReady(checks) + result := preflightResult{ + OK: true, + Ready: ready, + Workspace: core.CurrentWorkspace().Display(), + Target: target, + Identity: identity, + Execution: execution, + Checks: checks, + NextActions: actions, + Notice: output.GetNotice(), + } + + writePreflightResult(opts.Factory.IOStreams.Out, result, opts.Format) + if !ready { + return output.ErrBare(1) + } + return nil +} + +func resolveTargetShortcut(service, command string) (*common.Shortcut, error) { + service = strings.TrimSpace(service) + command = strings.TrimSpace(command) + for _, shortcut := range shortcuts.AllShortcuts() { + if shortcut.Service == service && shortcut.Command == command { + sc := shortcut + return &sc, nil + } + } + + var available []string + for _, shortcut := range shortcuts.AllShortcuts() { + if shortcut.Service == service { + available = append(available, shortcut.Command) + } + } + if len(available) == 0 { + return nil, output.ErrValidation("unknown shortcut target %q %q", service, command) + } + return nil, output.ErrWithHint(output.ExitValidation, "target_not_found", + fmt.Sprintf("shortcut %q not found in service %q", command, service), + fmt.Sprintf("available shortcuts for %s: %s", service, strings.Join(available, ", ")), + ) +} + +func resolvePreflightConfig(f *cmdutil.Factory) (*core.CliConfig, error) { + multi, err := core.LoadOrNotConfigured() + if err != nil { + return nil, err + } + cfg, err := core.ResolveConfigFromMulti(multi, f.Keychain, f.Invocation.Profile) + if err != nil { + return nil, err + } + return cfg, nil +} + +func resolvePreflightIdentity(opts *DoctorPreflightOptions, cfg *core.CliConfig) (core.Identity, string, error) { + requested := core.Identity(strings.TrimSpace(opts.RequestedAs)) + if requested == "" { + requested = core.AsAuto + } + switch requested { + case core.AsAuto, core.AsUser, core.AsBot: + default: + return "", "", output.ErrValidation("invalid --as %q: must be auto, user, or bot", opts.RequestedAs) + } + + cmd := &cobra.Command{Use: "preflight"} + cmd.Flags().String("as", "auto", "") + if requested != core.AsAuto { + _ = cmd.Flags().Set("as", string(requested)) + } + + resolved := opts.Factory.ResolveAs(opts.Ctx, cmd, requested) + source := "auto_detect" + if cmd.Flags().Changed("as") && requested != core.AsAuto { + source = "explicit_as" + } else if forced := opts.Factory.ResolveStrictMode(opts.Ctx).ForcedIdentity(); forced != "" { + source = "strict_mode" + } else if hint, err := opts.Factory.Credential.ResolveIdentityHint(opts.Ctx); err == nil && hint != nil && hint.DefaultAs != "" && hint.DefaultAs != core.AsAuto { + source = "default_as" + } else if cfg.DefaultAs != "" && cfg.DefaultAs != core.AsAuto { + source = "default_as" + } + return resolved, source, nil +} + +func runPreflightChecks(opts *DoctorPreflightOptions, cfg *core.CliConfig, shortcut *common.Shortcut, resolvedAs core.Identity, requiredScopes []string) ([]preflightCheck, []preflightAction) { + var checks []preflightCheck + var actions []preflightAction + + checks = append(checks, preflightCheck{ + Name: "config_ready", + Status: "pass", + Blocking: false, + Message: fmt.Sprintf("profile %s resolved for app %s (%s)", cfg.ProfileName, cfg.AppID, cfg.Brand), + }) + + mode := opts.Factory.ResolveStrictMode(opts.Ctx) + if mode.IsActive() && !mode.AllowsIdentity(resolvedAs) { + checks = append(checks, preflightCheck{ + Name: "strict_mode", + Status: "fail", + Blocking: true, + Message: fmt.Sprintf("strict mode is %q, only %s identity is allowed", mode, mode.ForcedIdentity()), + Hint: "see `lark-cli config strict-mode --help` before switching identity policy", + }) + actions = append(actions, preflightAction{ + Type: "strict_mode_help", + Blocking: true, + Command: "lark-cli config strict-mode --help", + Reason: "current profile policy blocks the requested identity", + }) + return appendRiskHints(checks, actions, shortcut, opts.Service, opts.Shortcut) + } + if mode.IsActive() { + checks = append(checks, preflightCheck{ + Name: "strict_mode", + Status: "pass", + Blocking: false, + Message: fmt.Sprintf("strict mode %q allows identity %s", mode, resolvedAs), + }) + } else { + checks = append(checks, preflightCheck{ + Name: "strict_mode", + Status: "pass", + Blocking: false, + Message: "strict mode is off", + }) + } + + if err := opts.Factory.CheckIdentity(resolvedAs, shortcutAuthTypes(shortcut)); err != nil { + checks = append(checks, preflightCheck{ + Name: "identity_supported", + Status: "fail", + Blocking: true, + Message: err.Error(), + }) + actions = append(actions, preflightAction{ + Type: "switch_identity", + Blocking: true, + Reason: "choose an identity supported by the target shortcut", + }) + return checks, actions + } + checks = append(checks, preflightCheck{ + Name: "identity_supported", + Status: "pass", + Blocking: false, + Message: fmt.Sprintf("shortcut supports resolved identity %s", resolvedAs), + }) + + if resolvedAs == core.AsUser { + tokenResult, tokenCheck, tokenAction, canCheckScopes := evaluateUserTokenReadiness(opts, cfg, requiredScopes) + checks = append(checks, tokenCheck) + if tokenAction != nil { + actions = append(actions, *tokenAction) + } + if tokenCheck.Blocking && tokenCheck.Status == "fail" { + return appendRiskHints(checks, actions, shortcut, opts.Service, opts.Shortcut) + } + if canCheckScopes { + scopeCheck, scopeAction := evaluateScopeReadiness(requiredScopes, tokenResult) + checks = append(checks, scopeCheck) + if scopeAction != nil { + actions = append(actions, *scopeAction) + } + } else { + checks = append(checks, preflightCheck{ + Name: "scope_ready", + Status: "unknown", + Blocking: false, + Message: "scope metadata is unavailable for the current token", + Hint: "run the target shortcut once if you need the server-side scope error details", + }) + } + } else { + checks = append(checks, preflightCheck{ + Name: "login_ready", + Status: "skip", + Blocking: false, + Message: "bot identity does not require user login", + }) + checks = append(checks, preflightCheck{ + Name: "scope_ready", + Status: "unknown", + Blocking: false, + Message: "bot app scope cannot be fully verified locally", + Hint: "if execution later returns a permission error, enable the required scope in developer console via console_url", + }) + } + + return appendRiskHints(checks, actions, shortcut, opts.Service, opts.Shortcut) +} + +func evaluateUserTokenReadiness(opts *DoctorPreflightOptions, cfg *core.CliConfig, requiredScopes []string) (*credential.TokenResult, preflightCheck, *preflightAction, bool) { + loginCommand := buildAuthLoginCommand(requiredScopes) + if cfg.UserOpenId == "" { + return nil, preflightCheck{ + Name: "login_ready", + Status: "fail", + Blocking: true, + Message: "no user logged in", + Hint: fmt.Sprintf("run `%s`", loginCommand), + }, &preflightAction{ + Type: "auth_login", + Blocking: true, + Command: loginCommand, + Reason: "user login is required for this shortcut", + }, false + } + + tokenResult, err := opts.Factory.Credential.ResolveToken(opts.Ctx, credential.NewTokenSpec(core.AsUser, cfg.AppID)) + if err != nil { + return nil, preflightCheck{ + Name: "login_ready", + Status: "fail", + Blocking: true, + Message: fmt.Sprintf("cannot resolve user token: %v", err), + Hint: fmt.Sprintf("run `%s` to re-authorize the user token", loginCommand), + }, &preflightAction{ + Type: "auth_login", + Blocking: true, + Command: loginCommand, + Reason: "user token is missing or cannot be refreshed", + }, false + } + + statusMsg := fmt.Sprintf("user token resolved for %s (%s)", cfg.UserName, cfg.UserOpenId) + if stored := preflightGetStoredToken(cfg.AppID, cfg.UserOpenId); stored != nil { + switch preflightTokenStatus(stored) { + case "valid": + statusMsg = fmt.Sprintf("user token is valid for %s (%s)", cfg.UserName, cfg.UserOpenId) + case "needs_refresh": + statusMsg = fmt.Sprintf("user token will refresh on next call for %s (%s)", cfg.UserName, cfg.UserOpenId) + default: + return nil, preflightCheck{ + Name: "login_ready", + Status: "fail", + Blocking: true, + Message: "stored user token is expired", + Hint: fmt.Sprintf("run `%s`", loginCommand), + }, &preflightAction{ + Type: "auth_login", + Blocking: true, + Command: loginCommand, + Reason: "stored user token expired and needs re-authorization", + }, false + } + } + + return tokenResult, preflightCheck{ + Name: "login_ready", + Status: "pass", + Blocking: false, + Message: statusMsg, + }, nil, true +} + +func evaluateScopeReadiness(requiredScopes []string, tokenResult *credential.TokenResult) (preflightCheck, *preflightAction) { + if len(requiredScopes) == 0 { + return preflightCheck{ + Name: "scope_ready", + Status: "pass", + Blocking: false, + Message: "shortcut does not declare extra user scopes", + }, nil + } + if tokenResult == nil || strings.TrimSpace(tokenResult.Scopes) == "" { + return preflightCheck{ + Name: "scope_ready", + Status: "unknown", + Blocking: false, + Message: "scope metadata is unavailable for the current token", + Hint: "run the target shortcut once if you need the server-side scope error details", + }, nil + } + + missing := internalauth.MissingScopes(tokenResult.Scopes, requiredScopes) + if len(missing) == 0 { + return preflightCheck{ + Name: "scope_ready", + Status: "pass", + Blocking: false, + Message: fmt.Sprintf("required scopes already granted: %s", strings.Join(requiredScopes, ", ")), + }, nil + } + + loginCommand := buildAuthLoginCommand(missing) + return preflightCheck{ + Name: "scope_ready", + Status: "fail", + Blocking: true, + Message: fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), + Hint: fmt.Sprintf("run `%s`", loginCommand), + }, &preflightAction{ + Type: "auth_login", + Blocking: true, + Command: loginCommand, + Reason: "grant the missing user scopes before executing the shortcut", + } +} + +func appendRiskHints(checks []preflightCheck, actions []preflightAction, shortcut *common.Shortcut, service, command string) ([]preflightCheck, []preflightAction) { + baseCmd := fmt.Sprintf("lark-cli %s %s", service, command) + switch shortcutRisk(shortcut) { + case "high-risk-write": + checks = append(checks, preflightCheck{ + Name: "risk", + Status: "warn", + Blocking: false, + Message: "high-risk write shortcut: preview the request before execution", + Hint: fmt.Sprintf("run `%s --dry-run` first; the real execution also requires `--yes`", baseCmd), + }) + actions = append(actions, preflightAction{ + Type: "dry_run", + Blocking: false, + Command: baseCmd + " --dry-run", + Reason: "preview the high-risk request before confirming with --yes", + }) + case "write": + checks = append(checks, preflightCheck{ + Name: "risk", + Status: "warn", + Blocking: false, + Message: "write shortcut: dry-run is recommended before execution", + Hint: fmt.Sprintf("run `%s --dry-run` first and then execute with the required business flags", baseCmd), + }) + actions = append(actions, preflightAction{ + Type: "dry_run", + Blocking: false, + Command: baseCmd + " --dry-run", + Reason: "preview the outgoing request before executing the write shortcut", + }) + default: + checks = append(checks, preflightCheck{ + Name: "risk", + Status: "pass", + Blocking: false, + Message: "read-only shortcut", + }) + } + return checks, actions +} + +func isPreflightReady(checks []preflightCheck) bool { + for _, check := range checks { + if check.Blocking && check.Status == "fail" { + return false + } + } + return true +} + +func writePreflightResult(w io.Writer, result preflightResult, format string) { + if format == "pretty" { + renderPreflightPretty(w, result) + return + } + output.PrintJson(w, result) +} + +func renderPreflightPretty(w io.Writer, result preflightResult) { + status := "READY" + if !result.Ready { + status = "NOT READY" + } + fmt.Fprintf(w, "Shortcut Preflight: %s\n", status) + fmt.Fprintf(w, "Workspace: %s\n", result.Workspace) + fmt.Fprintf(w, "Target: %s %s\n", result.Target.Service, result.Target.Command) + fmt.Fprintf(w, "Identity: requested=%s resolved=%s (%s)\n", result.Identity.Requested, result.Identity.Resolved, result.Identity.Source) + fmt.Fprintf(w, "Command: %s\n", result.Execution.Command) + if result.Execution.DryRunCommand != "" { + fmt.Fprintf(w, "Dry Run: %s\n", result.Execution.DryRunCommand) + } + if len(result.Target.Scopes) > 0 { + fmt.Fprintf(w, "Scopes: %s\n", strings.Join(result.Target.Scopes, ", ")) + } + if len(result.Execution.Flags) > 0 { + fmt.Fprintln(w, "Flags:") + for _, flag := range result.Execution.Flags { + required := "" + if flag.Required { + required = " [required]" + } + fmt.Fprintf(w, " - --%s%s: %s\n", flag.Name, required, flag.Description) + } + } + fmt.Fprintln(w, "") + fmt.Fprintln(w, "Checks:") + for _, check := range result.Checks { + blocking := "" + if check.Blocking { + blocking = " [blocking]" + } + fmt.Fprintf(w, " - [%s]%s %s: %s\n", check.Status, blocking, check.Name, check.Message) + if check.Hint != "" { + fmt.Fprintf(w, " hint: %s\n", check.Hint) + } + } + if len(result.NextActions) == 0 { + return + } + fmt.Fprintln(w, "") + fmt.Fprintln(w, "Next Actions:") + for _, action := range result.NextActions { + fmt.Fprintf(w, " - %s: %s\n", action.Type, action.Reason) + if action.Command != "" { + fmt.Fprintf(w, " command: %s\n", action.Command) + } + } +} + +func buildConfigFailureResult(target preflightTarget, identity preflightIdentity, err error) preflightResult { + check := preflightCheck{ + Name: "config_ready", + Status: "fail", + Blocking: true, + Message: err.Error(), + } + var cfgErr *core.ConfigError + if errors.As(err, &cfgErr) { + check.Message = cfgErr.Message + check.Hint = cfgErr.Hint + } + + var actions []preflightAction + switch { + case core.CurrentWorkspace().IsLocal(): + actions = append(actions, preflightAction{ + Type: "config_init", + Blocking: true, + Command: "lark-cli config init --new", + Reason: "initialize local app configuration before running the shortcut", + }) + case !core.CurrentWorkspace().IsLocal(): + actions = append(actions, preflightAction{ + Type: "config_bind_help", + Blocking: true, + Command: "lark-cli config bind --help", + Reason: "bind lark-cli to the Agent workspace before running the shortcut", + }) + } + + return preflightResult{ + OK: true, + Ready: false, + Workspace: core.CurrentWorkspace().Display(), + Target: target, + Identity: identity, + Execution: preflightExecution{Command: fmt.Sprintf("lark-cli %s %s --help", target.Service, target.Command)}, + Checks: []preflightCheck{check}, + NextActions: actions, + Notice: output.GetNotice(), + } +} + +func shortcutRisk(shortcut *common.Shortcut) string { + if shortcut == nil || shortcut.Risk == "" { + return "read" + } + return shortcut.Risk +} + +func shortcutAuthTypes(shortcut *common.Shortcut) []string { + if shortcut == nil || len(shortcut.AuthTypes) == 0 { + return []string{"user"} + } + return append([]string(nil), shortcut.AuthTypes...) +} + +func buildAuthLoginCommand(scopes []string) string { + if len(scopes) == 0 { + return "lark-cli auth login --help" + } + return fmt.Sprintf("lark-cli auth login --scope %q", strings.Join(scopes, " ")) +} + +func normalizedRequestedIdentity(requested string) string { + requested = strings.TrimSpace(requested) + if requested == "" { + return string(core.AsAuto) + } + return requested +} + +func buildPreflightExecution(shortcut *common.Shortcut, resolvedAs core.Identity) preflightExecution { + base := []string{"lark-cli", shortcut.Service, shortcut.Command} + if resolvedAs != "" { + base = append(base, "--as", string(resolvedAs)) + } + for _, flag := range shortcut.Flags { + if flag.Hidden || !flag.Required { + continue + } + base = appendRequiredFlagTemplate(base, flag) + } + + execution := preflightExecution{ + Command: strings.Join(base, " "), + SupportsDryRun: shortcut.DryRun != nil, + RequiresConfirmation: shortcutRisk(shortcut) == "high-risk-write", + Flags: buildPreflightExecutionFlags(shortcut.Flags), + Notes: buildPreflightExecutionNotes(shortcut), + } + if execution.RequiresConfirmation { + execution.Command += " --yes" + } + if execution.SupportsDryRun { + dryRun := append([]string(nil), base...) + dryRun = append(dryRun, "--dry-run") + execution.DryRunCommand = strings.Join(dryRun, " ") + } + return execution +} + +func buildPreflightExecutionFlags(flags []common.Flag) []preflightExecutionFlag { + var out []preflightExecutionFlag + for _, flag := range flags { + if flag.Hidden { + continue + } + out = append(out, preflightExecutionFlag{ + Name: flag.Name, + Type: normalizedFlagType(flag.Type), + Required: flag.Required, + Default: flag.Default, + Enum: append([]string(nil), flag.Enum...), + Input: append([]string(nil), flag.Input...), + Description: flag.Desc, + }) + } + return out +} + +func buildPreflightExecutionNotes(shortcut *common.Shortcut) []string { + var notes []string + if shortcut.Risk == "write" && shortcut.DryRun != nil { + notes = append(notes, "write shortcut: run dry_run_command before real execution") + } + if shortcutRisk(shortcut) == "high-risk-write" { + notes = append(notes, "high-risk write shortcut: real execution requires --yes") + if shortcut.DryRun != nil { + notes = append(notes, "preview with dry_run_command before confirming") + } + } + notes = append(notes, "field requirements come from shortcut metadata; dynamic validation rules remain in command --help") + return notes +} + +func flagPlaceholder(flag common.Flag) string { + if flag.Type == "bool" { + return "true" + } + return "<" + flag.Name + ">" +} + +func normalizedFlagType(flagType string) string { + if strings.TrimSpace(flagType) == "" { + return "string" + } + return flagType +} + +func appendRequiredFlagTemplate(args []string, flag common.Flag) []string { + args = append(args, "--"+flag.Name) + if flag.Type == "bool" { + return args + } + return append(args, flagPlaceholder(flag)) +} diff --git a/cmd/doctor/preflight_test.go b/cmd/doctor/preflight_test.go new file mode 100644 index 000000000..602882c9b --- /dev/null +++ b/cmd/doctor/preflight_test.go @@ -0,0 +1,576 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doctor + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" + + extcred "github.com/larksuite/cli/extension/credential" + internalauth "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/shortcuts/common" +) + +type preflightAccountResolver struct { + cfg *core.CliConfig +} + +func (r *preflightAccountResolver) ResolveAccount(ctx context.Context) (*credential.Account, error) { + return credential.AccountFromCliConfig(r.cfg), nil +} + +type preflightTokenResolver struct { + result *credential.TokenResult + err error +} + +func (r *preflightTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) { + return r.result, r.err +} + +func TestDoctorPreflight_NotConfiguredLocal(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + f := cmdutil.NewDefault(cmdutil.NewIOStreams(&bytes.Buffer{}, stdout, stderr), cmdutil.InvocationContext{}) + + cmd := NewCmdDoctor(f) + cmd.SetArgs([]string{"preflight", "calendar", "+agenda"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected non-nil error for not-ready preflight") + } + + var result preflightResult + if unmarshalErr := json.Unmarshal(stdout.Bytes(), &result); unmarshalErr != nil { + t.Fatalf("failed to parse stdout JSON: %v\n%s", unmarshalErr, stdout.String()) + } + if result.Ready { + t.Fatal("expected ready=false when config is missing") + } + if got := result.Checks[0].Name; got != "config_ready" { + t.Fatalf("first check = %q, want config_ready", got) + } + if len(result.NextActions) == 0 || result.NextActions[0].Command != "lark-cli config init --new" { + t.Fatalf("next action = %+v, want config init", result.NextActions) + } +} + +func TestNewCmdDoctorPreflight_InvalidFormat(t *testing.T) { + cfg := &core.CliConfig{ + ProfileName: "default", + AppID: "app-1", + AppSecret: "secret", + Brand: core.BrandFeishu, + } + f, _, _ := newPreflightFactory(t, cfg, &credential.TokenResult{Token: "tat-token"}) + + cmd := NewCmdDoctorPreflight(f) + cmd.SetArgs([]string{"calendar", "+agenda", "--format", "table"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected invalid format error") + } +} + +func TestDoctorPreflight_UserMissingScopes(t *testing.T) { + cfg := &core.CliConfig{ + ProfileName: "default", + AppID: "app-1", + AppSecret: "secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_123", + UserName: "Alice", + } + f, stdout, _ := newPreflightFactory(t, cfg, &credential.TokenResult{ + Token: "uat-token", + Scopes: "calendar:calendar:read", + }) + + cmd := NewCmdDoctor(f) + cmd.SetArgs([]string{"preflight", "calendar", "+agenda", "--as", "user"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected non-nil error for missing scopes") + } + + var result preflightResult + if unmarshalErr := json.Unmarshal(stdout.Bytes(), &result); unmarshalErr != nil { + t.Fatalf("failed to parse stdout JSON: %v\n%s", unmarshalErr, stdout.String()) + } + if result.Ready { + t.Fatal("expected ready=false when scopes are missing") + } + scopeCheck := findPreflightCheck(t, result.Checks, "scope_ready") + if scopeCheck.Status != "fail" { + t.Fatalf("scope_ready status = %q, want fail", scopeCheck.Status) + } + if len(result.NextActions) == 0 || result.NextActions[0].Type != "auth_login" { + t.Fatalf("next actions = %+v, want auth_login", result.NextActions) + } +} + +func TestDoctorPreflight_StrictModeConflict(t *testing.T) { + cfg := &core.CliConfig{ + ProfileName: "default", + AppID: "app-1", + AppSecret: "secret", + Brand: core.BrandFeishu, + SupportedIdentities: uint8(extcred.SupportsBot), + } + f, stdout, _ := newPreflightFactory(t, cfg, &credential.TokenResult{Token: "tat-token"}) + + cmd := NewCmdDoctor(f) + cmd.SetArgs([]string{"preflight", "calendar", "+agenda", "--as", "user"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected non-nil error for strict-mode conflict") + } + + var result preflightResult + if unmarshalErr := json.Unmarshal(stdout.Bytes(), &result); unmarshalErr != nil { + t.Fatalf("failed to parse stdout JSON: %v\n%s", unmarshalErr, stdout.String()) + } + strictCheck := findPreflightCheck(t, result.Checks, "strict_mode") + if strictCheck.Status != "fail" { + t.Fatalf("strict_mode status = %q, want fail", strictCheck.Status) + } + if len(result.NextActions) == 0 || result.NextActions[0].Command != "lark-cli config strict-mode --help" { + t.Fatalf("next actions = %+v, want strict-mode help", result.NextActions) + } +} + +func TestDoctorPreflight_WriteShortcutWarnsDryRun(t *testing.T) { + cfg := &core.CliConfig{ + ProfileName: "default", + AppID: "app-1", + AppSecret: "secret", + Brand: core.BrandFeishu, + } + f, stdout, _ := newPreflightFactory(t, cfg, &credential.TokenResult{Token: "tat-token"}) + + cmd := NewCmdDoctor(f) + cmd.SetArgs([]string{"preflight", "im", "+messages-send", "--as", "bot"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result preflightResult + if unmarshalErr := json.Unmarshal(stdout.Bytes(), &result); unmarshalErr != nil { + t.Fatalf("failed to parse stdout JSON: %v\n%s", unmarshalErr, stdout.String()) + } + if !result.Ready { + t.Fatal("expected ready=true for bot write shortcut") + } + riskCheck := findPreflightCheck(t, result.Checks, "risk") + if riskCheck.Status != "warn" { + t.Fatalf("risk status = %q, want warn", riskCheck.Status) + } + foundDryRun := false + for _, action := range result.NextActions { + if action.Type == "dry_run" { + foundDryRun = true + break + } + } + if !foundDryRun { + t.Fatalf("next actions = %+v, want dry_run action", result.NextActions) + } + if result.Execution.Command != "lark-cli im +messages-send --as bot" { + t.Fatalf("execution command = %q, want bot command template", result.Execution.Command) + } + if result.Execution.DryRunCommand != "lark-cli im +messages-send --as bot --dry-run" { + t.Fatalf("dry-run command = %q, want dry-run command template", result.Execution.DryRunCommand) + } + if !result.Execution.SupportsDryRun { + t.Fatal("expected supports_dry_run=true") + } +} + +func TestDoctorPreflight_HighRiskExecutionPlan(t *testing.T) { + cfg := &core.CliConfig{ + ProfileName: "default", + AppID: "app-1", + AppSecret: "secret", + Brand: core.BrandFeishu, + } + f, stdout, _ := newPreflightFactory(t, cfg, &credential.TokenResult{Token: "tat-token"}) + + cmd := NewCmdDoctor(f) + cmd.SetArgs([]string{"preflight", "wiki", "+delete-space", "--as", "bot"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result preflightResult + if unmarshalErr := json.Unmarshal(stdout.Bytes(), &result); unmarshalErr != nil { + t.Fatalf("failed to parse stdout JSON: %v\n%s", unmarshalErr, stdout.String()) + } + if result.Execution.Command != "lark-cli wiki +delete-space --as bot --space-id --yes" { + t.Fatalf("execution command = %q, want high-risk command template", result.Execution.Command) + } + if result.Execution.DryRunCommand != "lark-cli wiki +delete-space --as bot --space-id --dry-run" { + t.Fatalf("dry-run command = %q, want dry-run command template", result.Execution.DryRunCommand) + } + if !result.Execution.RequiresConfirmation { + t.Fatal("expected requires_confirmation=true") + } + if len(result.Execution.Flags) != 1 { + t.Fatalf("execution flags = %+v, want one required flag", result.Execution.Flags) + } + flag := result.Execution.Flags[0] + if flag.Name != "space-id" || !flag.Required || flag.Type != "string" { + t.Fatalf("execution flag = %+v, want required string space-id", flag) + } +} + +func TestResolveTargetShortcutErrors(t *testing.T) { + if _, err := resolveTargetShortcut("no-service", "+agenda"); err == nil { + t.Fatal("expected error for unknown service") + } + if _, err := resolveTargetShortcut("calendar", "+not-found"); err == nil { + t.Fatal("expected error for unknown shortcut command") + } +} + +func TestResolvePreflightIdentitySources(t *testing.T) { + t.Run("invalid as", func(t *testing.T) { + cfg := &core.CliConfig{AppID: "app-1", AppSecret: "secret", Brand: core.BrandFeishu} + f, _, _ := newPreflightFactory(t, cfg, &credential.TokenResult{Token: "tok"}) + _, _, err := resolvePreflightIdentity(&DoctorPreflightOptions{ + Factory: f, + Ctx: context.Background(), + RequestedAs: "demo", + }, cfg) + if err == nil { + t.Fatal("expected invalid as error") + } + }) + + t.Run("explicit as", func(t *testing.T) { + cfg := &core.CliConfig{AppID: "app-1", AppSecret: "secret", Brand: core.BrandFeishu} + f, _, _ := newPreflightFactory(t, cfg, &credential.TokenResult{Token: "tok"}) + as, source, err := resolvePreflightIdentity(&DoctorPreflightOptions{ + Factory: f, + Ctx: context.Background(), + RequestedAs: "bot", + }, cfg) + if err != nil { + t.Fatalf("resolvePreflightIdentity() error = %v", err) + } + if as != core.AsBot || source != "explicit_as" { + t.Fatalf("got (%s,%s), want (bot,explicit_as)", as, source) + } + }) + + t.Run("strict mode", func(t *testing.T) { + cfg := &core.CliConfig{ + AppID: "app-1", + AppSecret: "secret", + Brand: core.BrandFeishu, + SupportedIdentities: uint8(extcred.SupportsBot), + } + f, _, _ := newPreflightFactory(t, cfg, &credential.TokenResult{Token: "tok"}) + as, source, err := resolvePreflightIdentity(&DoctorPreflightOptions{ + Factory: f, + Ctx: context.Background(), + RequestedAs: "auto", + }, cfg) + if err != nil { + t.Fatalf("resolvePreflightIdentity() error = %v", err) + } + if as != core.AsBot || source != "strict_mode" { + t.Fatalf("got (%s,%s), want (bot,strict_mode)", as, source) + } + }) + + t.Run("default as", func(t *testing.T) { + cfg := &core.CliConfig{ + AppID: "app-1", + AppSecret: "secret", + Brand: core.BrandFeishu, + DefaultAs: core.AsUser, + } + f, _, _ := newPreflightFactory(t, cfg, &credential.TokenResult{Token: "tok"}) + as, source, err := resolvePreflightIdentity(&DoctorPreflightOptions{ + Factory: f, + Ctx: context.Background(), + RequestedAs: "auto", + }, cfg) + if err != nil { + t.Fatalf("resolvePreflightIdentity() error = %v", err) + } + if as != core.AsUser || source != "default_as" { + t.Fatalf("got (%s,%s), want (user,default_as)", as, source) + } + }) +} + +func TestEvaluateUserTokenReadinessBranches(t *testing.T) { + cfg := &core.CliConfig{ + AppID: "app-1", + AppSecret: "secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_1", + UserName: "alice", + } + + t.Run("no user logged in", func(t *testing.T) { + f, _, _ := newPreflightFactory(t, &core.CliConfig{ + AppID: "app-1", + AppSecret: "secret", + Brand: core.BrandFeishu, + }, &credential.TokenResult{Token: "tok"}) + _, check, action, ok := evaluateUserTokenReadiness(&DoctorPreflightOptions{Factory: f, Ctx: context.Background()}, &core.CliConfig{ + AppID: "app-1", + AppSecret: "secret", + Brand: core.BrandFeishu, + }, []string{"calendar:calendar.event:read"}) + if ok || check.Status != "fail" || action == nil { + t.Fatalf("got ok=%v check=%+v action=%+v", ok, check, action) + } + }) + + t.Run("token resolve error", func(t *testing.T) { + f, _, _ := newPreflightFactory(t, cfg, nil) + f.Credential = credential.NewCredentialProvider(nil, &preflightAccountResolver{cfg: cfg}, &preflightTokenResolver{err: errors.New("boom")}, nil) + _, check, action, ok := evaluateUserTokenReadiness(&DoctorPreflightOptions{Factory: f, Ctx: context.Background()}, cfg, []string{"calendar:calendar.event:read"}) + if ok || check.Status != "fail" || action == nil { + t.Fatalf("got ok=%v check=%+v action=%+v", ok, check, action) + } + }) + + t.Run("expired stored token", func(t *testing.T) { + restorePreflightTokenFuncs(t) + preflightGetStoredToken = func(appID, openID string) *internalauth.StoredUAToken { + return &internalauth.StoredUAToken{AppId: appID, UserOpenId: openID} + } + preflightTokenStatus = func(token *internalauth.StoredUAToken) string { return "expired" } + f, _, _ := newPreflightFactory(t, cfg, &credential.TokenResult{Token: "tok"}) + _, check, action, ok := evaluateUserTokenReadiness(&DoctorPreflightOptions{Factory: f, Ctx: context.Background()}, cfg, []string{"calendar:calendar.event:read"}) + if ok || check.Status != "fail" || action == nil { + t.Fatalf("got ok=%v check=%+v action=%+v", ok, check, action) + } + }) + + t.Run("needs refresh", func(t *testing.T) { + restorePreflightTokenFuncs(t) + preflightGetStoredToken = func(appID, openID string) *internalauth.StoredUAToken { + return &internalauth.StoredUAToken{AppId: appID, UserOpenId: openID} + } + preflightTokenStatus = func(token *internalauth.StoredUAToken) string { return "needs_refresh" } + f, _, _ := newPreflightFactory(t, cfg, &credential.TokenResult{Token: "tok"}) + _, check, action, ok := evaluateUserTokenReadiness(&DoctorPreflightOptions{Factory: f, Ctx: context.Background()}, cfg, []string{"calendar:calendar.event:read"}) + if !ok || check.Status != "pass" || action != nil { + t.Fatalf("got ok=%v check=%+v action=%+v", ok, check, action) + } + }) +} + +func TestEvaluateScopeReadinessBranches(t *testing.T) { + check, action := evaluateScopeReadiness(nil, &credential.TokenResult{Token: "tok"}) + if check.Status != "pass" || action != nil { + t.Fatalf("no-scope branch = %+v %+v", check, action) + } + + check, action = evaluateScopeReadiness([]string{"calendar:calendar.event:read"}, &credential.TokenResult{Token: "tok"}) + if check.Status != "unknown" || action != nil { + t.Fatalf("unknown-scope branch = %+v %+v", check, action) + } + + check, action = evaluateScopeReadiness([]string{"calendar:calendar.event:read"}, &credential.TokenResult{ + Token: "tok", + Scopes: "calendar:calendar.event:read", + }) + if check.Status != "pass" || action != nil { + t.Fatalf("granted-scope branch = %+v %+v", check, action) + } +} + +func TestWritePreflightResultPrettyAndConfigFailure(t *testing.T) { + t.Run("pretty output", func(t *testing.T) { + var buf bytes.Buffer + writePreflightResult(&buf, preflightResult{ + OK: true, + Ready: true, + Workspace: "local", + Target: preflightTarget{ + Service: "calendar", + Command: "+agenda", + Scopes: []string{"calendar:calendar.event:read"}, + }, + Identity: preflightIdentity{ + Requested: "auto", + Resolved: "user", + Source: "default_as", + }, + Execution: preflightExecution{ + Command: "lark-cli calendar +agenda --as user", + DryRunCommand: "lark-cli calendar +agenda --as user --dry-run", + SupportsDryRun: true, + Flags: []preflightExecutionFlag{{ + Name: "calendar-id", + Description: "calendar id", + }}, + }, + Checks: []preflightCheck{{ + Name: "config_ready", + Status: "pass", + Message: "ok", + }}, + NextActions: []preflightAction{{ + Type: "dry_run", + Command: "lark-cli calendar +agenda --dry-run", + Reason: "preview first", + }}, + }, "pretty") + out := buf.String() + for _, want := range []string{"Shortcut Preflight: READY", "Flags:", "Next Actions:", "Dry Run:"} { + if !strings.Contains(out, want) { + t.Fatalf("pretty output missing %q:\n%s", want, out) + } + } + }) + + t.Run("agent workspace config failure", func(t *testing.T) { + prev := core.CurrentWorkspace() + core.SetCurrentWorkspace(core.WorkspaceHermes) + t.Cleanup(func() { core.SetCurrentWorkspace(prev) }) + result := buildConfigFailureResult(preflightTarget{ + Service: "calendar", + Command: "+agenda", + }, preflightIdentity{Requested: "auto"}, &core.ConfigError{ + Code: 2, + Type: "hermes", + Message: "hermes context detected but lark-cli is not bound to it", + Hint: "read `lark-cli config bind --help`", + }) + if result.NextActions[0].Command != "lark-cli config bind --help" { + t.Fatalf("next action = %+v, want bind help", result.NextActions) + } + }) +} + +func TestHelperFunctions(t *testing.T) { + if got := shortcutRisk(nil); got != "read" { + t.Fatalf("shortcutRisk(nil) = %q, want read", got) + } + if got := shortcutAuthTypes(nil); len(got) != 1 || got[0] != "user" { + t.Fatalf("shortcutAuthTypes(nil) = %#v, want [user]", got) + } + if got := buildAuthLoginCommand(nil); got != "lark-cli auth login --help" { + t.Fatalf("buildAuthLoginCommand(nil) = %q", got) + } + if got := normalizedRequestedIdentity(""); got != "auto" { + t.Fatalf("normalizedRequestedIdentity(\"\") = %q", got) + } + if got := normalizedFlagType(""); got != "string" { + t.Fatalf("normalizedFlagType(\"\") = %q", got) + } + if got := normalizedFlagType("bool"); got != "bool" { + t.Fatalf("normalizedFlagType(\"bool\") = %q", got) + } + if got := flagPlaceholder(common.Flag{Name: "apply", Type: "bool"}); got != "true" { + t.Fatalf("flagPlaceholder(bool) = %q", got) + } + if isPreflightReady([]preflightCheck{{Status: "fail", Blocking: true}}) { + t.Fatal("expected ready=false for blocking fail") + } +} + +func TestAppendRequiredFlagTemplate_BoolFlag(t *testing.T) { + args := appendRequiredFlagTemplate([]string{"lark-cli", "demo", "+run"}, common.Flag{ + Name: "apply", + Type: "bool", + Required: true, + }) + want := []string{"lark-cli", "demo", "+run", "--apply"} + if len(args) != len(want) { + t.Fatalf("args = %#v, want %#v", args, want) + } + for i := range want { + if args[i] != want[i] { + t.Fatalf("args = %#v, want %#v", args, want) + } + } +} + +func TestBuildPreflightExecutionHelpers(t *testing.T) { + flags := buildPreflightExecutionFlags([]common.Flag{ + {Name: "title", Desc: "title"}, + {Name: "hidden", Hidden: true}, + }) + if len(flags) != 1 || flags[0].Type != "string" { + t.Fatalf("buildPreflightExecutionFlags() = %+v", flags) + } + + notes := buildPreflightExecutionNotes(&common.Shortcut{ + Risk: "write", + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI() + }, + }) + if len(notes) == 0 { + t.Fatal("expected notes for write shortcut") + } +} + +func newPreflightFactory(t *testing.T, cfg *core.CliConfig, token *credential.TokenResult) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + core.SetCurrentWorkspace(core.WorkspaceLocal) + + multi := &core.MultiAppConfig{ + CurrentApp: cfg.ProfileName, + Apps: []core.AppConfig{{ + Name: cfg.ProfileName, + AppId: cfg.AppID, + AppSecret: core.PlainSecret(cfg.AppSecret), + Brand: cfg.Brand, + DefaultAs: cfg.DefaultAs, + Users: []core.AppUser{{UserOpenId: cfg.UserOpenId, UserName: cfg.UserName}}, + }}, + } + if cfg.ProfileName == "" { + multi.CurrentApp = "" + multi.Apps[0].Name = "" + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + f := cmdutil.NewDefault(cmdutil.NewIOStreams(&bytes.Buffer{}, stdout, stderr), cmdutil.InvocationContext{Profile: cfg.ProfileName}) + f.Credential = credential.NewCredentialProvider(nil, &preflightAccountResolver{cfg: cfg}, &preflightTokenResolver{result: token}, nil) + return f, stdout, stderr +} + +func findPreflightCheck(t *testing.T, checks []preflightCheck, name string) preflightCheck { + t.Helper() + for _, check := range checks { + if check.Name == name { + return check + } + } + t.Fatalf("check %q not found in %+v", name, checks) + return preflightCheck{} +} + +func restorePreflightTokenFuncs(t *testing.T) { + t.Helper() + origGet := preflightGetStoredToken + origStatus := preflightTokenStatus + t.Cleanup(func() { + preflightGetStoredToken = origGet + preflightTokenStatus = origStatus + }) +}