mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
* feat: unconditionally inject --format flag for all shortcuts Removes three HasFormat guards in runner.go so every shortcut gets --format regardless of the Shortcut.HasFormat field value. Shortcuts that already define a custom 'format' flag in Flags[] are skipped to avoid redefinition panics (e.g. mail +triage, +watch). HasFormat is retained in the struct but marked deprecated. Change-Id: I5e8fe07e839d5aed4cefaf7d753dabbaee68fb6e * test: isolate config dir in format-universal test Change-Id: I3a59942aa8a6753cd949ca42f2a19a72f032ff55 * test: revert unnecessary config-dir isolation (mount-only test) Change-Id: I0146e5a2f57f5419863bdeeaa1a662fd8f70bddf
1039 lines
35 KiB
Go
1039 lines
35 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package common
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/google/uuid"
|
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/extension/fileio"
|
|
"github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/client"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/credential"
|
|
"github.com/larksuite/cli/internal/i18n"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// RuntimeContext provides helpers for shortcut execution.
|
|
type RuntimeContext struct {
|
|
ctx context.Context // from cmd.Context(), propagated through the call chain
|
|
Config *core.CliConfig
|
|
Cmd *cobra.Command
|
|
Format string
|
|
JqExpr string // --jq expression; empty = no filter
|
|
outputErrOnce sync.Once // guards first-error capture in Out()/OutFormat()
|
|
outputErr error // deferred error from jq filtering; written at most once
|
|
botOnly bool // set by framework for bot-only shortcuts
|
|
resolvedAs core.Identity // effective identity resolved by framework
|
|
Factory *cmdutil.Factory // injected by framework
|
|
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
|
|
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
|
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
|
}
|
|
|
|
// ── Identity ──
|
|
|
|
// As returns the current identity.
|
|
// For bot-only shortcuts, always returns AsBot.
|
|
// For dual-auth shortcuts, uses the resolved identity (respects default-as config).
|
|
func (ctx *RuntimeContext) As() core.Identity {
|
|
if ctx.botOnly {
|
|
return core.AsBot
|
|
}
|
|
if ctx.resolvedAs.IsBot() {
|
|
return core.AsBot
|
|
}
|
|
if ctx.resolvedAs != "" {
|
|
return ctx.resolvedAs
|
|
}
|
|
return core.AsUser
|
|
}
|
|
|
|
// IsBot returns true if current identity is bot.
|
|
func (ctx *RuntimeContext) IsBot() bool {
|
|
return ctx.As().IsBot()
|
|
}
|
|
|
|
// UserOpenId returns the current user's open_id from config.
|
|
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
|
|
|
|
// Lang returns the user's preference as a canonical locale, or "" if unset or
|
|
// unrecognized; callers choose their own fallback.
|
|
func (ctx *RuntimeContext) Lang() i18n.Lang {
|
|
lang, _ := i18n.Parse(string(ctx.Config.Lang))
|
|
return lang
|
|
}
|
|
|
|
// BotInfo holds bot identity metadata fetched lazily from /bot/v3/info.
|
|
type BotInfo struct {
|
|
OpenID string
|
|
AppName string
|
|
}
|
|
|
|
// BotInfo returns the bot's open_id and display name, fetched lazily from /bot/v3/info.
|
|
// Unlike UserOpenId() (which reads from config), this requires a network call and may fail.
|
|
// Thread-safe via sync.OnceValues; the API is called at most once per RuntimeContext.
|
|
func (ctx *RuntimeContext) BotInfo() (*BotInfo, error) {
|
|
if ctx.botInfoFunc == nil {
|
|
return nil, fmt.Errorf("BotInfo not available (runtime context not fully initialized)")
|
|
}
|
|
return ctx.botInfoFunc()
|
|
}
|
|
|
|
// fetchBotInfo calls /bot/v3/info using bot identity and parses the response.
|
|
func (ctx *RuntimeContext) fetchBotInfo() (*BotInfo, error) {
|
|
if !ctx.Config.CanBot() {
|
|
return nil, fmt.Errorf("fetch bot info: bot identity is not available in current credential context")
|
|
}
|
|
resp, err := ctx.DoAPIAsBot(&larkcore.ApiReq{
|
|
HttpMethod: http.MethodGet,
|
|
ApiPath: "/open-apis/bot/v3/info",
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch bot info: %w", err)
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("fetch bot info: HTTP %d", resp.StatusCode)
|
|
}
|
|
// /open-apis/bot/v3/info returns `{code, msg, bot: {...}}` — the bot
|
|
// payload is under "bot", not "data" as the newer Lark API convention.
|
|
var envelope struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data struct {
|
|
OpenID string `json:"open_id"`
|
|
AppName string `json:"app_name"`
|
|
} `json:"bot"`
|
|
}
|
|
if err := json.Unmarshal(resp.RawBody, &envelope); err != nil {
|
|
return nil, fmt.Errorf("fetch bot info: unmarshal: %w", err)
|
|
}
|
|
if envelope.Code != 0 {
|
|
return nil, fmt.Errorf("fetch bot info: [%d] %s", envelope.Code, envelope.Msg)
|
|
}
|
|
if envelope.Data.OpenID == "" {
|
|
return nil, fmt.Errorf("fetch bot info: open_id is empty")
|
|
}
|
|
return &BotInfo{OpenID: envelope.Data.OpenID, AppName: envelope.Data.AppName}, nil
|
|
}
|
|
|
|
// Ctx returns the context.Context propagated from cmd.Context().
|
|
func (ctx *RuntimeContext) Ctx() context.Context { return ctx.ctx }
|
|
|
|
// getAPIClient returns the cached APIClient, creating it on first use.
|
|
// Thread-safe via sync.OnceValues (initialized in newRuntimeContext).
|
|
// Falls back to direct construction for test contexts that bypass newRuntimeContext.
|
|
func (ctx *RuntimeContext) getAPIClient() (*client.APIClient, error) {
|
|
if ctx.apiClientFunc != nil {
|
|
return ctx.apiClientFunc()
|
|
}
|
|
return ctx.Factory.NewAPIClientWithConfig(ctx.Config)
|
|
}
|
|
|
|
// AccessToken returns a valid access token for the current identity.
|
|
// For user: returns user access token (with auto-refresh).
|
|
// For bot: returns tenant access token.
|
|
func (ctx *RuntimeContext) AccessToken() (string, error) {
|
|
result, err := ctx.Factory.Credential.ResolveToken(ctx.ctx, credential.NewTokenSpec(ctx.As(), ctx.Config.AppID))
|
|
if err != nil {
|
|
return "", output.ErrAuth("failed to get access token: %s", err)
|
|
}
|
|
if result == nil || result.Token == "" {
|
|
return "", output.ErrAuth("no access token available for %s", ctx.As())
|
|
}
|
|
return result.Token, nil
|
|
}
|
|
|
|
// LarkSDK returns the eagerly-initialized Lark SDK client.
|
|
func (ctx *RuntimeContext) LarkSDK() *lark.Client {
|
|
return ctx.larkSDK
|
|
}
|
|
|
|
// EnsureScopes runs the same pre-flight scope check used by the framework
|
|
// before Validate, but on a caller-supplied set of scopes. Use it from a
|
|
// shortcut's Validate to enforce conditional scope requirements that depend
|
|
// on flag values (e.g. --delete-remote needing space:document:delete) so a
|
|
// destructive operation never starts on a token that can't finish it.
|
|
//
|
|
// Behavior matches checkShortcutScopes: when no token is available or the
|
|
// resolver doesn't expose scope metadata, this is a silent no-op — the
|
|
// downstream API call still surfaces missing_scope at runtime.
|
|
func (ctx *RuntimeContext) EnsureScopes(scopes []string) error {
|
|
return checkShortcutScopes(ctx.Factory, ctx.ctx, ctx.As(), ctx.Config, scopes)
|
|
}
|
|
|
|
// ── Flag accessors ──
|
|
|
|
// Str returns a string flag value.
|
|
func (ctx *RuntimeContext) Str(name string) string {
|
|
v, _ := ctx.Cmd.Flags().GetString(name)
|
|
return v
|
|
}
|
|
|
|
// Bool returns a bool flag value.
|
|
func (ctx *RuntimeContext) Bool(name string) bool {
|
|
v, _ := ctx.Cmd.Flags().GetBool(name)
|
|
return v
|
|
}
|
|
|
|
// Int returns an int flag value.
|
|
func (ctx *RuntimeContext) Int(name string) int {
|
|
v, _ := ctx.Cmd.Flags().GetInt(name)
|
|
return v
|
|
}
|
|
|
|
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
|
|
func (ctx *RuntimeContext) StrArray(name string) []string {
|
|
v, _ := ctx.Cmd.Flags().GetStringArray(name)
|
|
return v
|
|
}
|
|
|
|
// StrSlice returns a string-slice flag value (supports CSV splitting and repeated flags).
|
|
func (ctx *RuntimeContext) StrSlice(name string) []string {
|
|
v, _ := ctx.Cmd.Flags().GetStringSlice(name)
|
|
return v
|
|
}
|
|
|
|
// Changed reports whether the user explicitly set the named flag on the
|
|
// command line, as opposed to the flag carrying its default value.
|
|
func (ctx *RuntimeContext) Changed(name string) bool {
|
|
f := ctx.Cmd.Flags().Lookup(name)
|
|
if f == nil {
|
|
return false
|
|
}
|
|
return f.Changed
|
|
}
|
|
|
|
// ── API helpers ──
|
|
|
|
// CallAPI uses an internal HTTP wrapper with limited control over request/response.
|
|
//
|
|
// Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options.
|
|
//
|
|
// CallAPI calls the Lark API using the current identity (ctx.As()) and auto-handles errors.
|
|
func (ctx *RuntimeContext) CallAPI(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
|
result, err := ctx.callRaw(method, url, params, data)
|
|
return HandleApiResult(result, err, "API call failed")
|
|
}
|
|
|
|
// Deprecated: RawAPI uses an internal HTTP wrapper with limited control over request/response.
|
|
// Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options.
|
|
//
|
|
// RawAPI calls the Lark API using the current identity (ctx.As()) and returns raw result for manual error handling.
|
|
func (ctx *RuntimeContext) RawAPI(method, url string, params map[string]interface{}, data interface{}) (interface{}, error) {
|
|
return ctx.callRaw(method, url, params, data)
|
|
}
|
|
|
|
// PaginateAll fetches all pages and returns a single merged result.
|
|
func (ctx *RuntimeContext) PaginateAll(method, url string, params map[string]interface{}, data interface{}, opts client.PaginationOptions) (interface{}, error) {
|
|
ac, err := ctx.getAPIClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req := ctx.buildRequest(method, url, params, data)
|
|
return ac.PaginateAll(ctx.ctx, req, opts)
|
|
}
|
|
|
|
// StreamPages fetches all pages and streams each page's items via onItems.
|
|
// Returns the last result (for error checking) and whether any list items were found.
|
|
func (ctx *RuntimeContext) StreamPages(method, url string, params map[string]interface{}, data interface{}, onItems func([]interface{}), opts client.PaginationOptions) (interface{}, bool, error) {
|
|
ac, err := ctx.getAPIClient()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
req := ctx.buildRequest(method, url, params, data)
|
|
return ac.StreamPages(ctx.ctx, req, onItems, opts)
|
|
}
|
|
|
|
func (ctx *RuntimeContext) buildRequest(method, url string, params map[string]interface{}, data interface{}) client.RawApiRequest {
|
|
req := client.RawApiRequest{
|
|
Method: method,
|
|
URL: url,
|
|
Params: params,
|
|
Data: data,
|
|
As: ctx.As(),
|
|
}
|
|
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
|
|
req.ExtraOpts = append(req.ExtraOpts, optFn)
|
|
}
|
|
return req
|
|
}
|
|
|
|
func (ctx *RuntimeContext) callRaw(method, url string, params map[string]interface{}, data interface{}) (interface{}, error) {
|
|
ac, err := ctx.getAPIClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ac.CallAPI(ctx.ctx, ctx.buildRequest(method, url, params, data))
|
|
}
|
|
|
|
// DoAPI executes a raw Lark SDK request with automatic auth handling.
|
|
// Unlike CallAPI which parses JSON and extracts the "data" field, DoAPI returns
|
|
// the raw *larkcore.ApiResp — suitable for file downloads (WithFileDownload)
|
|
// and uploads (WithFileUpload).
|
|
//
|
|
// Auth resolution is delegated to APIClient.DoSDKRequest to avoid duplicating
|
|
// the identity → token logic across the generic and shortcut API paths.
|
|
func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
|
ac, err := ctx.getAPIClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
|
|
opts = append(opts, optFn)
|
|
}
|
|
return ac.DoSDKRequest(ctx.ctx, req, ctx.As(), opts...)
|
|
}
|
|
|
|
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),
|
|
// regardless of the current --as flag. Use this for APIs that must always be called
|
|
// with TAT even when the surrounding shortcut runs as user.
|
|
func (ctx *RuntimeContext) DoAPIAsBot(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
|
ac, err := ctx.getAPIClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
|
|
opts = append(opts, optFn)
|
|
}
|
|
return ac.DoSDKRequest(ctx.ctx, req, core.AsBot, opts...)
|
|
}
|
|
|
|
// DoAPIStream executes a streaming HTTP request via APIClient.DoStream.
|
|
// Unlike DoAPI (which buffers the full body via the SDK), DoAPIStream returns
|
|
// a live *http.Response whose Body is an io.Reader for streaming consumption.
|
|
// HTTP errors (status >= 400) are handled internally by DoStream.
|
|
func (ctx *RuntimeContext) DoAPIStream(callCtx context.Context, req *larkcore.ApiReq, opts ...client.Option) (*http.Response, error) {
|
|
ac, err := ctx.getAPIClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
base := []client.Option{
|
|
client.WithHeaders(cmdutil.BaseSecurityHeaders()),
|
|
}
|
|
if h := cmdutil.ShortcutHeaders(ctx.ctx); h != nil {
|
|
base = append(base, client.WithHeaders(h))
|
|
}
|
|
return ac.DoStream(callCtx, req, ctx.As(), append(base, opts...)...)
|
|
}
|
|
|
|
// DoAPIJSON calls the Lark API via DoAPI, parses the JSON response envelope,
|
|
// and returns the "data" field. Suitable for standard JSON APIs (non-file).
|
|
func (ctx *RuntimeContext) DoAPIJSON(method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) {
|
|
return ctx.doAPIJSON(method, apiPath, query, body, false)
|
|
}
|
|
|
|
// DoAPIJSONWithLogID is like DoAPIJSON but merges x-tt-logid from the response
|
|
// header into the returned data and into error details as "log_id". Intended
|
|
// for endpoints where surfacing the log id aids troubleshooting (e.g. doc v2).
|
|
func (ctx *RuntimeContext) DoAPIJSONWithLogID(method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) {
|
|
return ctx.doAPIJSON(method, apiPath, query, body, true)
|
|
}
|
|
|
|
func (ctx *RuntimeContext) doAPIJSON(method, apiPath string, query larkcore.QueryParams, body any, includeLogID bool) (map[string]any, error) {
|
|
req := &larkcore.ApiReq{
|
|
HttpMethod: method,
|
|
ApiPath: apiPath,
|
|
QueryParams: query,
|
|
}
|
|
if body != nil {
|
|
req.Body = body
|
|
}
|
|
resp, err := ctx.DoAPI(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var detail map[string]any
|
|
if includeLogID {
|
|
detail = logIDFromHeader(resp)
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
if len(resp.RawBody) > 0 {
|
|
var errEnv struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
}
|
|
if json.Unmarshal(resp.RawBody, &errEnv) == nil && errEnv.Msg != "" {
|
|
return nil, output.ErrAPI(errEnv.Code, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, errEnv.Msg), detail)
|
|
}
|
|
}
|
|
return nil, output.ErrAPI(resp.StatusCode, fmt.Sprintf("HTTP %d", resp.StatusCode), detail)
|
|
}
|
|
if len(resp.RawBody) == 0 {
|
|
return nil, fmt.Errorf("empty response body")
|
|
}
|
|
var envelope struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(resp.RawBody, &envelope); err != nil {
|
|
return nil, fmt.Errorf("unmarshal response: %w", err)
|
|
}
|
|
if envelope.Code != 0 {
|
|
return nil, output.ErrAPI(envelope.Code, envelope.Msg, detail)
|
|
}
|
|
if detail != nil {
|
|
if envelope.Data == nil {
|
|
envelope.Data = make(map[string]any)
|
|
}
|
|
for k, v := range detail {
|
|
envelope.Data[k] = v
|
|
}
|
|
}
|
|
return envelope.Data, nil
|
|
}
|
|
|
|
// logIDFromHeader extracts x-tt-logid from response headers and returns it as a detail map.
|
|
// Returns nil if the header is absent.
|
|
func logIDFromHeader(resp *larkcore.ApiResp) map[string]any {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
logID := resp.Header.Get("x-tt-logid")
|
|
if logID == "" {
|
|
return nil
|
|
}
|
|
return map[string]any{"log_id": logID}
|
|
}
|
|
|
|
// ── IO access ──
|
|
|
|
// IO returns the IOStreams from the Factory.
|
|
func (ctx *RuntimeContext) IO() *cmdutil.IOStreams {
|
|
return ctx.Factory.IOStreams
|
|
}
|
|
|
|
// FileIO resolves the FileIO using the current execution context.
|
|
// Falls back to the globally registered provider when Factory or its
|
|
// FileIOProvider is nil (e.g. in lightweight test helpers).
|
|
func (ctx *RuntimeContext) FileIO() fileio.FileIO {
|
|
if ctx != nil && ctx.Factory != nil {
|
|
if fio := ctx.Factory.ResolveFileIO(ctx.ctx); fio != nil {
|
|
return fio
|
|
}
|
|
}
|
|
if p := fileio.GetProvider(); p != nil {
|
|
c := context.Background()
|
|
if ctx != nil {
|
|
c = ctx.ctx
|
|
}
|
|
return p.ResolveFileIO(c)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResolveSavePath resolves a relative path to a validated absolute path via
|
|
// FileIO.ResolvePath. It returns an error if no FileIO provider is registered
|
|
// or if the path fails validation (e.g. traversal, symlink escape).
|
|
func (ctx *RuntimeContext) ResolveSavePath(path string) (string, error) {
|
|
fio := ctx.FileIO()
|
|
if fio == nil {
|
|
return "", fmt.Errorf("no file I/O provider registered")
|
|
}
|
|
resolved, err := fio.ResolvePath(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve save path: %w", err)
|
|
}
|
|
if resolved == "" {
|
|
return "", fmt.Errorf("resolve save path: empty result for %q", path)
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
// WrapSaveError matches a FileIO.Save error against known categories and wraps
|
|
// it with the caller-provided message prefix, preserving backward-compatible
|
|
// error text per shortcut.
|
|
func WrapSaveError(err error, pathMsg, mkdirMsg, writeMsg string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var me *fileio.MkdirError
|
|
var we *fileio.WriteError
|
|
switch {
|
|
case errors.Is(err, fileio.ErrPathValidation):
|
|
return fmt.Errorf("%s: %w", pathMsg, err)
|
|
case errors.As(err, &me):
|
|
return fmt.Errorf("%s: %w", mkdirMsg, err)
|
|
case errors.As(err, &we):
|
|
return fmt.Errorf("%s: %w", writeMsg, err)
|
|
default:
|
|
return fmt.Errorf("%s: %w", writeMsg, err)
|
|
}
|
|
}
|
|
|
|
// WrapOpenError matches a FileIO.Open/Stat error and wraps it with the
|
|
// caller-provided message prefix.
|
|
func WrapOpenError(err error, pathMsg, readMsg string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if errors.Is(err, fileio.ErrPathValidation) {
|
|
return fmt.Errorf("%s: %w", pathMsg, err)
|
|
}
|
|
return fmt.Errorf("%s: %w", readMsg, err)
|
|
}
|
|
|
|
// WrapInputStatError wraps a FileIO.Stat/Open error for input file validation,
|
|
// returning output.ErrValidation with the appropriate message:
|
|
// - Path validation failures → "unsafe file path: ..."
|
|
// - Other errors → readMsg prefix (default "cannot read file")
|
|
//
|
|
// Pass an optional readMsg to override the non-path-validation message prefix.
|
|
func WrapInputStatError(err error, readMsg ...string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if errors.Is(err, fileio.ErrPathValidation) {
|
|
return output.ErrValidation("unsafe file path: %s", err)
|
|
}
|
|
msg := "cannot read file"
|
|
if len(readMsg) > 0 && readMsg[0] != "" {
|
|
msg = readMsg[0]
|
|
}
|
|
return output.ErrValidation("%s: %s", msg, err)
|
|
}
|
|
|
|
// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
|
|
// using standardized messages and the given error category (e.g. "api_error", "io").
|
|
// Path validation errors always use ErrValidation (exit code 2).
|
|
func WrapSaveErrorByCategory(err error, category string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var me *fileio.MkdirError
|
|
switch {
|
|
case errors.Is(err, fileio.ErrPathValidation):
|
|
return output.ErrValidation("unsafe output path: %s", err)
|
|
case errors.As(err, &me):
|
|
return output.Errorf(output.ExitInternal, category, "cannot create parent directory: %s", err)
|
|
default:
|
|
return output.Errorf(output.ExitInternal, category, "cannot create file: %s", err)
|
|
}
|
|
}
|
|
|
|
// ValidatePath checks that path is a valid relative input path within the
|
|
// working directory by delegating to FileIO.Stat. Returns nil if the path is
|
|
// valid or does not exist yet; returns an error only for illegal paths
|
|
// (absolute, traversal, symlink escape, control chars).
|
|
//
|
|
// NOTE: This validates input (read) paths via SafeInputPath semantics inside
|
|
// the FileIO implementation. For output (write) path validation, use
|
|
// ResolveSavePath instead.
|
|
func (ctx *RuntimeContext) ValidatePath(path string) error {
|
|
fio := ctx.FileIO()
|
|
if fio == nil {
|
|
return fmt.Errorf("no file I/O provider registered")
|
|
}
|
|
if _, err := fio.Stat(path); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Output helpers ──
|
|
|
|
// Out prints a success JSON envelope to stdout.
|
|
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
|
|
ctx.emit(data, meta, false)
|
|
}
|
|
|
|
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
|
|
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
|
|
// that should be preserved as-is in JSON output.
|
|
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
|
|
ctx.emit(data, meta, true)
|
|
}
|
|
|
|
// emit is the shared success-path emitter. raw=true disables JSON HTML escaping so
|
|
// XML/HTML payloads (e.g. DocxXML bodies) are preserved verbatim; otherwise behavior
|
|
// is identical — content-safety scanning and race-safe first-error capture via
|
|
// outputErrOnce apply in both modes.
|
|
func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw bool) {
|
|
scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut)
|
|
if scanResult.Blocked {
|
|
ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr })
|
|
return
|
|
}
|
|
|
|
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
|
|
if scanResult.Alert != nil {
|
|
env.ContentSafetyAlert = scanResult.Alert
|
|
}
|
|
|
|
if ctx.JqExpr != "" {
|
|
filter := output.JqFilter
|
|
if raw {
|
|
filter = output.JqFilterRaw
|
|
}
|
|
if err := filter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
|
|
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
|
|
ctx.outputErrOnce.Do(func() { ctx.outputErr = err })
|
|
}
|
|
return
|
|
}
|
|
|
|
if raw {
|
|
enc := json.NewEncoder(ctx.IO().Out)
|
|
enc.SetEscapeHTML(false)
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(env)
|
|
return
|
|
}
|
|
b, _ := json.MarshalIndent(env, "", " ")
|
|
fmt.Fprintln(ctx.IO().Out, string(b))
|
|
}
|
|
|
|
// OutFormat prints output based on --format flag.
|
|
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
|
|
// When JqExpr is set, routes through Out() regardless of format.
|
|
// For json/"" and jq paths, Out() handles content safety scanning.
|
|
// For pretty/table/csv/ndjson, scanning is done here and the alert is written to stderr.
|
|
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
|
|
ctx.outFormat(data, meta, prettyFn, false)
|
|
}
|
|
|
|
// OutFormatRaw is like OutFormat but with HTML escaping disabled in JSON output.
|
|
// Use this when the data contains XML/HTML content that should be preserved as-is.
|
|
func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
|
|
ctx.outFormat(data, meta, prettyFn, true)
|
|
}
|
|
|
|
func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) {
|
|
outFn := ctx.Out
|
|
if raw {
|
|
outFn = ctx.OutRaw
|
|
}
|
|
if ctx.JqExpr != "" {
|
|
outFn(data, meta)
|
|
return
|
|
}
|
|
switch ctx.Format {
|
|
case "pretty":
|
|
scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut)
|
|
if scanResult.Blocked {
|
|
ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr })
|
|
return
|
|
}
|
|
if scanResult.Alert != nil {
|
|
output.WriteAlertWarning(ctx.IO().ErrOut, scanResult.Alert)
|
|
}
|
|
if prettyFn != nil {
|
|
prettyFn(ctx.IO().Out)
|
|
} else {
|
|
outFn(data, meta)
|
|
}
|
|
case "json", "":
|
|
outFn(data, meta)
|
|
default:
|
|
// table, csv, ndjson — pass data directly; FormatValue handles both
|
|
// plain arrays and maps with array fields (e.g. {"members":[…]})
|
|
scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut)
|
|
if scanResult.Blocked {
|
|
ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr })
|
|
return
|
|
}
|
|
if scanResult.Alert != nil {
|
|
output.WriteAlertWarning(ctx.IO().ErrOut, scanResult.Alert)
|
|
}
|
|
format, formatOK := output.ParseFormat(ctx.Format)
|
|
if !formatOK {
|
|
fmt.Fprintf(ctx.IO().ErrOut, "warning: unknown format %q, falling back to json\n", ctx.Format)
|
|
}
|
|
output.FormatValue(ctx.IO().Out, data, format)
|
|
}
|
|
}
|
|
|
|
// ── Scope pre-check ──
|
|
|
|
// checkScopePrereqs performs a fast local check: does the token
|
|
// contain all scopes declared by the shortcut? Returns the missing ones.
|
|
// If scope data is unavailable, returns nil (let the API call handle it).
|
|
func checkScopePrereqs(f *cmdutil.Factory, ctx context.Context, appID string, identity core.Identity, required []string) ([]string, error) {
|
|
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(identity, appID))
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return nil, err
|
|
}
|
|
return nil, nil
|
|
}
|
|
if result == nil || result.Scopes == "" {
|
|
return nil, nil
|
|
}
|
|
return auth.MissingScopes(result.Scopes, required), nil
|
|
}
|
|
|
|
// enhancePermissionError enriches a permission / auth error with the
|
|
// shortcut's declared required scopes so the user knows exactly what to do.
|
|
//
|
|
// Detection is typed: an error qualifies when it (or any error in its
|
|
// Unwrap chain) is *errs.PermissionError, or — for legacy bridge paths —
|
|
// when it is an *output.ExitError carrying Detail.Type "permission" or
|
|
// "missing_scope". The previous implementation scanned the upstream
|
|
// message text for keywords like "permission" / "scope" / "unauthorized",
|
|
// which was brittle to canonical-message rewrites; routing on the typed
|
|
// shape decouples this helper from the wording.
|
|
func enhancePermissionError(err error, requiredScopes []string) error {
|
|
var permErr *errs.PermissionError
|
|
if errors.As(err, &permErr) {
|
|
scopeDisplay := strings.Join(requiredScopes, ", ")
|
|
scopeArg := strings.Join(requiredScopes, " ")
|
|
hint := fmt.Sprintf(
|
|
"this command requires scope(s): %s\nrun `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
|
|
scopeDisplay, scopeArg)
|
|
permErr.Hint = hint
|
|
return err
|
|
}
|
|
|
|
var exitErr *output.ExitError
|
|
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
|
return err
|
|
}
|
|
if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "missing_scope" {
|
|
return err
|
|
}
|
|
|
|
scopeDisplay := strings.Join(requiredScopes, ", ")
|
|
scopeArg := strings.Join(requiredScopes, " ")
|
|
hint := fmt.Sprintf(
|
|
"this command requires scope(s): %s\nrun `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
|
|
scopeDisplay, scopeArg)
|
|
// Return a new error instead of mutating the original's Detail in place.
|
|
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
|
|
}
|
|
|
|
// ── Mounting ──
|
|
|
|
// Mount registers the shortcut on a parent command.
|
|
func (s Shortcut) Mount(parent *cobra.Command, f *cmdutil.Factory) {
|
|
s.MountWithContext(context.Background(), parent, f)
|
|
}
|
|
|
|
func (s Shortcut) MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
|
if s.Execute != nil {
|
|
s.mountDeclarative(ctx, parent, f)
|
|
}
|
|
}
|
|
|
|
func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
|
shortcut := s
|
|
if len(shortcut.AuthTypes) == 0 {
|
|
shortcut.AuthTypes = []string{"user"}
|
|
}
|
|
botOnly := len(shortcut.AuthTypes) == 1 && shortcut.AuthTypes[0] == "bot"
|
|
|
|
cmd := &cobra.Command{
|
|
Use: shortcut.Command,
|
|
Short: shortcut.Description,
|
|
Hidden: shortcut.Hidden,
|
|
Args: rejectPositionalArgs(),
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
return runShortcut(cmd, f, &shortcut, botOnly)
|
|
},
|
|
}
|
|
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
|
|
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
|
|
cmdutil.SetTips(cmd, shortcut.Tips)
|
|
cmdutil.SetRisk(cmd, shortcut.Risk)
|
|
parent.AddCommand(cmd)
|
|
if shortcut.PostMount != nil {
|
|
shortcut.PostMount(cmd)
|
|
}
|
|
}
|
|
|
|
// runShortcut is the execution pipeline for a declarative shortcut.
|
|
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
|
|
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
|
|
as, err := resolveShortcutIdentity(cmd, f, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
config, err := f.Config()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Identity info is now included in the JSON envelope; skip stderr printing.
|
|
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, as, config, false)
|
|
|
|
if err := checkShortcutScopes(f, cmd.Context(), as, config, s.ScopesForIdentity(string(as))); err != nil {
|
|
return err
|
|
}
|
|
|
|
rctx, err := newRuntimeContext(cmd, f, s, config, as, botOnly)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := validateEnumFlags(rctx, s.Flags); err != nil {
|
|
return err
|
|
}
|
|
if err := resolveInputFlags(rctx, s.Flags); err != nil {
|
|
return err
|
|
}
|
|
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
|
|
return err
|
|
}
|
|
if s.Validate != nil {
|
|
if err := s.Validate(rctx.ctx, rctx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if rctx.Bool("dry-run") {
|
|
return handleShortcutDryRun(f, rctx, s)
|
|
}
|
|
|
|
if s.Risk == "high-risk-write" && !rctx.Bool("yes") {
|
|
return cmdutil.RequireConfirmation(s.Service + " " + s.Command)
|
|
}
|
|
|
|
if err := s.Execute(rctx.ctx, rctx); err != nil {
|
|
return err
|
|
}
|
|
return rctx.outputErr
|
|
}
|
|
|
|
func resolveShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (core.Identity, error) {
|
|
// Step 1: determine identity (--as > default-as > auto-detect).
|
|
asFlag, _ := cmd.Flags().GetString("as")
|
|
as := f.ResolveAs(cmd.Context(), cmd, core.Identity(asFlag))
|
|
|
|
if err := f.CheckStrictMode(cmd.Context(), as); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Step 2: check if this shortcut supports the resolved identity.
|
|
if err := f.CheckIdentity(as, s.AuthTypes); err != nil {
|
|
return "", err
|
|
}
|
|
return as, nil
|
|
}
|
|
|
|
func checkShortcutScopes(f *cmdutil.Factory, ctx context.Context, as core.Identity, config *core.CliConfig, scopes []string) error {
|
|
if len(scopes) == 0 {
|
|
return nil
|
|
}
|
|
missing, err := checkScopePrereqs(f, ctx, config.AppID, as, scopes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(missing) == 0 {
|
|
return nil
|
|
}
|
|
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
|
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
|
WithIdentity(string(as)).
|
|
WithMissingScopes(missing...).
|
|
WithHint("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))
|
|
}
|
|
|
|
func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) (*RuntimeContext, error) {
|
|
ctx := cmd.Context()
|
|
ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String())
|
|
rctx := &RuntimeContext{ctx: ctx, Config: config, Cmd: cmd, botOnly: botOnly, resolvedAs: as, Factory: f}
|
|
rctx.apiClientFunc = sync.OnceValues(func() (*client.APIClient, error) {
|
|
return f.NewAPIClientWithConfig(config)
|
|
})
|
|
rctx.botInfoFunc = sync.OnceValues(rctx.fetchBotInfo)
|
|
|
|
sdk, err := f.LarkClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rctx.larkSDK = sdk
|
|
|
|
rctx.Format = rctx.Str("format")
|
|
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
|
return rctx, nil
|
|
}
|
|
|
|
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
|
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
|
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
|
stdinUsed := false
|
|
for _, fl := range flags {
|
|
if len(fl.Input) == 0 {
|
|
continue
|
|
}
|
|
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
|
|
if err != nil {
|
|
return FlagErrorf("--%s: Input is only supported for string flags", fl.Name)
|
|
}
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
|
|
// stdin: -
|
|
if raw == "-" {
|
|
if !slices.Contains(fl.Input, Stdin) {
|
|
return FlagErrorf("--%s does not support stdin (-)", fl.Name)
|
|
}
|
|
if stdinUsed {
|
|
return FlagErrorf("--%s: stdin (-) can only be used by one flag", fl.Name)
|
|
}
|
|
stdinUsed = true
|
|
data, err := io.ReadAll(rctx.IO().In)
|
|
if err != nil {
|
|
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
|
|
}
|
|
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
|
continue
|
|
}
|
|
|
|
// escape: @@ → literal @
|
|
if strings.HasPrefix(raw, "@@") {
|
|
rctx.Cmd.Flags().Set(fl.Name, raw[1:]) // strip first @
|
|
continue
|
|
}
|
|
|
|
// file: @path
|
|
if strings.HasPrefix(raw, "@") {
|
|
if !slices.Contains(fl.Input, File) {
|
|
return FlagErrorf("--%s does not support file input (@path)", fl.Name)
|
|
}
|
|
path := strings.TrimSpace(raw[1:])
|
|
if path == "" {
|
|
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
|
|
}
|
|
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
|
if err != nil {
|
|
return FlagErrorf("--%s: %v", fl.Name, err)
|
|
}
|
|
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
|
continue
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
|
|
for _, fl := range flags {
|
|
if len(fl.Enum) == 0 {
|
|
continue
|
|
}
|
|
val := rctx.Str(fl.Name)
|
|
if val == "" {
|
|
continue
|
|
}
|
|
valid := false
|
|
for _, allowed := range fl.Enum {
|
|
if val == allowed {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
if !valid {
|
|
return FlagErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", "))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
|
|
if s.DryRun == nil {
|
|
return FlagErrorf("--dry-run is not supported for %s %s", s.Service, s.Command)
|
|
}
|
|
fmt.Fprintln(f.IOStreams.ErrOut, "=== Dry Run ===")
|
|
dryResult := s.DryRun(rctx.ctx, rctx)
|
|
if rctx.Format == "pretty" {
|
|
fmt.Fprint(f.IOStreams.Out, dryResult.Format())
|
|
} else {
|
|
output.PrintJson(f.IOStreams.Out, dryResult)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// rejectPositionalArgs returns a cobra.PositionalArgs that rejects any
|
|
// positional arguments. The error is intentionally a plain error (not
|
|
// ExitError) so that cobra prints usage and the root handler prints a
|
|
// simple "Error:" line instead of a JSON envelope.
|
|
func rejectPositionalArgs() cobra.PositionalArgs {
|
|
return func(cmd *cobra.Command, args []string) error {
|
|
if len(args) == 0 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("positional arguments are not supported (got %q); pass values via flags", args)
|
|
}
|
|
}
|
|
|
|
func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
|
|
registerShortcutFlagsWithContext(context.Background(), cmd, f, s)
|
|
}
|
|
|
|
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
|
|
for _, fl := range s.Flags {
|
|
desc := fl.Desc
|
|
if len(fl.Enum) > 0 {
|
|
desc += " (" + strings.Join(fl.Enum, "|") + ")"
|
|
}
|
|
if len(fl.Input) > 0 {
|
|
hints := make([]string, 0, 2)
|
|
if slices.Contains(fl.Input, File) {
|
|
hints = append(hints, "@file")
|
|
}
|
|
if slices.Contains(fl.Input, Stdin) {
|
|
hints = append(hints, "- for stdin")
|
|
}
|
|
desc += " (supports " + strings.Join(hints, ", ") + ")"
|
|
}
|
|
switch fl.Type {
|
|
case "bool":
|
|
def := fl.Default == "true"
|
|
cmd.Flags().Bool(fl.Name, def, desc)
|
|
case "int":
|
|
var d int
|
|
fmt.Sscanf(fl.Default, "%d", &d)
|
|
cmd.Flags().Int(fl.Name, d, desc)
|
|
case "string_array":
|
|
cmd.Flags().StringArray(fl.Name, nil, desc)
|
|
case "string_slice":
|
|
cmd.Flags().StringSlice(fl.Name, nil, desc)
|
|
default:
|
|
cmd.Flags().String(fl.Name, fl.Default, desc)
|
|
}
|
|
if fl.Hidden {
|
|
_ = cmd.Flags().MarkHidden(fl.Name)
|
|
}
|
|
if fl.Required {
|
|
cmd.MarkFlagRequired(fl.Name)
|
|
}
|
|
if len(fl.Enum) > 0 {
|
|
vals := fl.Enum
|
|
cmdutil.RegisterFlagCompletion(cmd, fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
|
return vals, cobra.ShellCompDirectiveNoFileComp
|
|
})
|
|
}
|
|
}
|
|
|
|
cmd.Flags().Bool("dry-run", false, "print request without executing")
|
|
if cmd.Flags().Lookup("format") == nil {
|
|
cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv")
|
|
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
|
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
|
})
|
|
}
|
|
if s.Risk == "high-risk-write" {
|
|
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
|
}
|
|
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
|
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
|
}
|