mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
835b52cc88
* feat(sheets): cut the top command-error clusters from the 08-18..24 eval batch
A trace analysis over 14,818 `lark-cli sheets` calls attributed 2,036 command
errors to 57 (subcommand, flag) groups, with the top 6 covering 76%. Four of
them are ours to fix; each is addressed at the layer that produced it.
--styles vocabulary (291 cases, the only group whose retry also failed):
- Prescribe the border family as a whole. foldBorderFamilyAliases already
absorbs border / borders / border_<side> / border_<attr>; what still
reached the error path was the Lark OpenAPI's border_type (FULL_BORDER,
OUTER_BORDER) and CSS's border_width — real vocabularies with no
equivalent here, and border_type was the single top field in the group.
Neither maps unambiguously onto a per-side style/weight/color triple, so
they get one shared answer, not a silent alias.
- Prescribe the OpenAPI's nested {range, style:{...}} envelope, plus
bg_color / fill_color / text_color.
- Match prescriptions on the key's letters alone, so border_type,
borderType and border-type are one mistake, not three.
- Collapse repeated issues in the --styles / --writes folds. One wrong
field name in a payload styling N cells produced N identical issues, each
re-listing the full supported vocabulary: the fold meant to save round
trips was burying its own answer. The defect is now stated once and the
other locations are named.
--sheets payload (419 cases):
- Accept dtypes / formats as a positional array. The same pandas habit that
produces `columns` and `data` produces df.dtypes.tolist(), and the
payloads were otherwise correct. Only a 1:1 match with `columns` is
accepted; a length mismatch is rejected rather than guessed.
+csv-put --file (35 cases):
- Read the value as a path. --file is aliased onto --csv because agents
reach for it, but the names promise different things — rewriting only the
name left the path to be written into the sheet as literal text, which the
file-path guard then rejected, with an error naming a flag the caller
never typed. The read goes through the same cmdutil.ReadInputFile as
@file, so the relative-path policy is unchanged and stdin stays the
out-of-tree route. A value naming nothing readable still falls through to
the guard, so --file holding literal CSV keeps working.
--help (88 cases of "required flag(s) ... not set"):
- Mark required flags in the sheets help. MarkFlagRequired only sets a
completion annotation cobra never renders, so a required flag read exactly
like an optional one. Sourced from flag-defs, since +chart-create and
+csv-put deliberately clear that annotation after mounting; a flag cobra
has put in a one-required group is left unmarked, because neither member
of such a pair is individually required.
The remaining big group (absolute paths passed to --file / @file, 636 cases)
is deliberately untouched: the cwd-relative policy is a protocol decision, and
that thread is being followed up separately.
* fix(sheets): correct --file alias provenance and mark its value resolved
Two defects in the +csv-put --file rule from the previous commit, both found
in review:
- The alias record was written from the flag-name normalizer, which pflag also
runs for Lookup and Set — including once with the canonical name right after
the rewrite, and again on every later lookup. `--file a.csv --csv ./b.csv`
therefore still counted as "supplied by --file", and an explicit --csv path
was silently read as a file instead of meeting its guard. The spelling is now
staged by the normalizer and committed by the flag's Value, which runs once
per real occurrence, so the last occurrence wins in either order.
- The rewritten value was not marked as read from a source, so a file whose
contents are themselves path-shaped ("report.csv") failed csvPutInput's shape
check — a valid CSV rejected as a caller who forgot the @. Marking it makes
--file behave exactly like --csv @<path> all the way down, which also lets
the guard skip it on its own rather than through a special case in Validate.
RuntimeContext gains an exported MarkInputResolved for the second half: the bit
already existed for @file / stdin, and a domain that resolves a source itself
needs to set it. No other domain calls it, so nothing else changes.
Also assert the typed contract (Param, Cause) rather than message text alone in
the style-prescription corpus and the collapsed-issue fold, per the repo's
error-test guideline.
* fix(sheets): answer every unreadable --file path under --file, and stage alias provenance only while parsing
Both from self-review of the branch.
An unreadable path passed as --file fell through to the --csv guard, which
answered naming a flag the caller never typed — and for a file that exists but
cannot be opened, prescribed "pass the same path with an @ prefix", which routes
through this very reader and fails identically. Only one case may fall through
now: a value that names nothing AND is not path-shaped, i.e. literal CSV text,
which --file accepted before this rule existed. A path-shaped value naming
nothing, an unreadable file, and a directory each answer under --file.
The alias spelling was staged with no Parsed() guard (the first commit had one;
flagalias.Bind still does). chainFlagAliases looks its aliases up while
installing and pflag normalizes on Lookup, so composing PostMount twice — which
installAliasProvenance explicitly anticipates — replayed "file" through the
already-installed normalizer at mount time, and the next real --csv occurrence
committed it: an explicit --csv path would then be read from disk instead of
meeting its guard. Verified the new regression test fails without the guard.
* fix(sheets): reset alias staging on remount, and assert cause on every --file read error
Second review pass, both valid.
FlagSet.Parsed() stays true once parsing has started, so the guard added in the
last commit only covers a remount that happens BEFORE the first parse. A remount
afterwards — its own alias lookups running through the normalizer the first pass
installed — could still leave a spelling staged for the next occurrence to
commit, which would read an explicit --csv path from disk. Re-running the
install now resets staging, closing the window from the other side. Verified the
new parse/remount/reparse test fails without the reset.
The unreadable-file and directory branches preserve the read error as Cause;
their tests now assert it, matching the sibling that already did.
1593 lines
58 KiB
Go
1593 lines
58 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/cmdmeta"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/credential"
|
|
"github.com/larksuite/cli/internal/errclass"
|
|
"github.com/larksuite/cli/internal/fileevent"
|
|
"github.com/larksuite/cli/internal/i18n"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
// 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
|
|
declaredScopes []string // shortcut-declared scopes for the resolved identity
|
|
reportOnce sync.Once // lazily initializes the command-scoped upload report budget
|
|
reportBudget *fileevent.Budget // shared by every upload report in this command
|
|
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
|
|
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
|
|
inputResolved map[string]bool // flags whose value was replaced by @file / stdin content in resolveInputFlags; see InputResolvedFromSource
|
|
offline bool // dry-run context: API and credential-backed scope checks are disabled
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
|
|
// PresentError renders a typed producer error for this command tree before a
|
|
// shortcut copies its fields into a result payload.
|
|
func (ctx *RuntimeContext) PresentError(err error) error {
|
|
if ctx == nil {
|
|
return err
|
|
}
|
|
return ctx.Factory.PresentError(err, cmdutil.ErrorPresentationOptions{
|
|
Identity: ctx.As(),
|
|
DeclaredScopes: func() []string {
|
|
return slices.Clone(ctx.declaredScopes)
|
|
},
|
|
})
|
|
}
|
|
|
|
// IsBot returns true if current identity is bot.
|
|
func (ctx *RuntimeContext) IsBot() bool {
|
|
return ctx.As().IsBot()
|
|
}
|
|
|
|
// Command returns the shortcut command name as cobra knows it (e.g.
|
|
// "+pivot-create"). Used by per-service helpers (e.g. sheets schema
|
|
// validation) that key off the shortcut identity.
|
|
func (ctx *RuntimeContext) Command() string {
|
|
if ctx.Cmd == nil {
|
|
return ""
|
|
}
|
|
return ctx.Cmd.Name()
|
|
}
|
|
|
|
// CommandPath returns the full command path as Cobra knows it. Consumers that
|
|
// expose command identity externally can normalize the binary name as needed.
|
|
func (ctx *RuntimeContext) CommandPath() string {
|
|
if ctx == nil || ctx.Cmd == nil {
|
|
return ""
|
|
}
|
|
return ctx.Cmd.CommandPath()
|
|
}
|
|
|
|
// FileEventBudget returns the reporting budget shared by every upload within
|
|
// this command invocation.
|
|
func (ctx *RuntimeContext) FileEventBudget() *fileevent.Budget {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
ctx.reportOnce.Do(func() {
|
|
ctx.reportBudget = fileevent.NewBudget()
|
|
})
|
|
return ctx.reportBudget
|
|
}
|
|
|
|
// 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.offline {
|
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "BotInfo is unavailable during dry-run")
|
|
}
|
|
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.offline {
|
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "OpenAPI requests are unavailable during dry-run")
|
|
}
|
|
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.resolveAccessToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return result.Token, nil
|
|
}
|
|
|
|
// resolveAccessToken retains the request-scoped origin metadata needed by
|
|
// direct HTTP paths such as MCP. Callers that only need the token value should
|
|
// use AccessToken.
|
|
func (ctx *RuntimeContext) resolveAccessToken() (*credential.TokenResult, error) {
|
|
result, err := ctx.Factory.Credential.ResolveToken(ctx.ctx, credential.NewTokenSpec(ctx.As(), ctx.Config.AppID))
|
|
if err != nil {
|
|
// ResolveToken classifies its own failures (config/api); pass those
|
|
// through so a typed lower-layer error is not flattened to token_invalid.
|
|
if _, ok := errs.ProblemOf(err); ok {
|
|
return nil, err
|
|
}
|
|
return nil, errs.NewAuthenticationError(errs.SubtypeTokenInvalid, "failed to get access token: %s", err).WithCause(err)
|
|
}
|
|
if result == nil || result.Token == "" {
|
|
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "no access token available for %s", ctx.As())
|
|
}
|
|
return result, 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 {
|
|
if ctx.offline {
|
|
return nil
|
|
}
|
|
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
|
|
}
|
|
|
|
// Float64 returns a float64 flag value (non-integer numbers).
|
|
func (ctx *RuntimeContext) Float64(name string) float64 {
|
|
v, _ := ctx.Cmd.Flags().GetFloat64(name)
|
|
return v
|
|
}
|
|
|
|
// IntArray returns an int-array flag value (repeated flag, also supports CSV splitting).
|
|
func (ctx *RuntimeContext) IntArray(name string) []int {
|
|
v, _ := ctx.Cmd.Flags().GetIntSlice(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 parsing or compatibility normalization populated the
|
|
// named flag, as opposed to the flag carrying only its default value. During a
|
|
// Normalize hook, check legacy and canonical spellings before SetCanonical to
|
|
// distinguish which spelling the caller supplied.
|
|
func (ctx *RuntimeContext) Changed(name string) bool {
|
|
f := ctx.Cmd.Flags().Lookup(name)
|
|
if f == nil {
|
|
return false
|
|
}
|
|
return f.Changed
|
|
}
|
|
|
|
// ── API helpers ──
|
|
|
|
// CallAPITyped calls the Lark API using the current identity (ctx.As()) via
|
|
// the SDK request path (buildRequest → APIClient.DoAPI → DoSDKRequest) and
|
|
// returns the "data" object, classifying failures into typed errs.* errors via
|
|
// errclass.BuildAPIError.
|
|
//
|
|
// A transport / auth error from the client boundary is already typed and passes
|
|
// through unchanged; a non-zero API response code is classified into a typed
|
|
// error carrying subtype / code / log_id.
|
|
//
|
|
// It lifts x-tt-logid from the response header (which the body-only parse drops)
|
|
// so log_id surfaces on the typed error even when the server returns it only in
|
|
// the header.
|
|
func (ctx *RuntimeContext) CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
|
ac, err := ctx.getAPIClient()
|
|
if err != nil {
|
|
return nil, typedOrInternal(err)
|
|
}
|
|
resp, err := ac.DoAPI(ctx.ctx, ctx.buildRequest(method, url, params, data))
|
|
if err != nil {
|
|
return nil, typedOrInternal(err)
|
|
}
|
|
return ctx.ClassifyAPIResponse(resp)
|
|
}
|
|
|
|
// ClassifyAPIResponse turns a raw *larkcore.ApiResp into the "data" object or a
|
|
// typed errs.* error. It is the shared response classifier for typed API paths
|
|
// — used by CallAPITyped and by callers that drive the request themselves
|
|
// (e.g. file upload via DoAPI). It:
|
|
//
|
|
// 1. parses the JSON body; an unparseable body on an HTTP error status (a
|
|
// gateway 5xx text/html page, an empty body, a missing Content-Type) is
|
|
// classified by status — 5xx → retryable network/server_error, 404 →
|
|
// not_found, other 4xx → api error — not a misleading invalid-response
|
|
// internal error;
|
|
// 2. rejects a top-level non-object JSON ([], null, scalar) as an
|
|
// invalid-response internal error — never a silent success ack;
|
|
// 3. lifts x-tt-logid from the response header onto the typed error so log_id
|
|
// surfaces even when the body omits it;
|
|
// 4. classifies a non-zero API code via errclass.BuildAPIError, and treats any
|
|
// HTTP error status that parsed to code==0 as a status error.
|
|
//
|
|
// The success "data" object is returned untouched. On a non-zero API code the
|
|
// data is returned alongside the typed error, since the response can still
|
|
// carry fields a caller needs on failure (e.g. the file_token an overwrite
|
|
// returned, for token-stability handling).
|
|
func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[string]interface{}, error) {
|
|
return ClassifyAPIResponseWith(resp, ctx.APIClassifyContext())
|
|
}
|
|
|
|
// ClassifyAPIResponseWith is the RuntimeContext-free form of
|
|
// ClassifyAPIResponse for callers that drive the request outside a running
|
|
// shortcut (e.g. a cobra command holding only a factory) and supply their own
|
|
// classification context.
|
|
func ClassifyAPIResponseWith(resp *larkcore.ApiResp, cc errclass.ClassifyContext) (map[string]interface{}, error) {
|
|
logID, _ := logIDFromHeader(resp)["log_id"].(string)
|
|
|
|
result, parseErr := client.ParseJSONResponse(resp)
|
|
if parseErr != nil {
|
|
if resp.StatusCode >= 400 {
|
|
return nil, httpStatusError(resp.StatusCode, resp.RawBody, logID)
|
|
}
|
|
return nil, client.WrapJSONResponseParseError(parseErr, resp.RawBody)
|
|
}
|
|
resultMap, ok := result.(map[string]interface{})
|
|
if !ok {
|
|
e := errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
|
|
if logID != "" {
|
|
e = e.WithLogID(logID)
|
|
}
|
|
return nil, e
|
|
}
|
|
if logID != "" {
|
|
if _, present := resultMap["log_id"]; !present {
|
|
resultMap["log_id"] = logID
|
|
}
|
|
}
|
|
out, _ := resultMap["data"].(map[string]interface{})
|
|
if apiErr := errclass.BuildAPIError(resultMap, cc); apiErr != nil {
|
|
return out, apiErr
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return out, httpStatusError(resp.StatusCode, resp.RawBody, logID)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// httpStatusError classifies an HTTP error status whose body is not a usable
|
|
// API envelope: 5xx → retryable network/server_error, 404 → not_found, other
|
|
// 4xx → api error. The x-tt-logid (when present) is attached for diagnosis.
|
|
func httpStatusError(status int, rawBody []byte, logID string) error {
|
|
body := TruncateStr(strings.TrimSpace(string(rawBody)), 500)
|
|
if status >= 500 {
|
|
e := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", status, body).WithCode(status).WithRetryable()
|
|
if logID != "" {
|
|
e = e.WithLogID(logID)
|
|
}
|
|
return e
|
|
}
|
|
subtype := errs.SubtypeUnknown
|
|
if status == http.StatusNotFound {
|
|
subtype = errs.SubtypeNotFound
|
|
}
|
|
e := errs.NewAPIError(subtype, "HTTP %d: %s", status, body).WithCode(status)
|
|
if logID != "" {
|
|
e = e.WithLogID(logID)
|
|
}
|
|
return e
|
|
}
|
|
|
|
// typedOrInternal passes an already-typed errs.* error through unchanged and
|
|
// lifts a still-untyped one to a typed internal error, so CallAPITyped never
|
|
// returns a bare/legacy error.
|
|
func typedOrInternal(err error) error {
|
|
if _, ok := errs.ProblemOf(err); ok {
|
|
return err
|
|
}
|
|
return errs.WrapInternal(err)
|
|
}
|
|
|
|
// APIClassifyContext builds the errclass.ClassifyContext for the running command
|
|
// from the runtime config and resolved identity.
|
|
func (ctx *RuntimeContext) APIClassifyContext() errclass.ClassifyContext {
|
|
larkCmd := ""
|
|
if ctx.Cmd != nil {
|
|
larkCmd = strings.TrimPrefix(ctx.Cmd.CommandPath(), "lark ")
|
|
}
|
|
return errclass.ClassifyContext{
|
|
Brand: string(ctx.Config.Brand),
|
|
AppID: ctx.Config.AppID,
|
|
Identity: string(ctx.As()),
|
|
LarkCmd: larkCmd,
|
|
}
|
|
}
|
|
|
|
// 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, func(items []interface{}) error {
|
|
onItems(items)
|
|
return nil
|
|
}, opts)
|
|
}
|
|
|
|
// buildRequest converts shortcut inputs into the raw request consumed by APIClient.
|
|
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
|
|
}
|
|
|
|
// callRaw executes a shortcut request and returns the unprojected API response.
|
|
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) {
|
|
return ctx.DoAPIWithContext(ctx.ctx, req, opts...)
|
|
}
|
|
|
|
// DoAPIWithContext executes a raw Lark SDK request using callCtx for request
|
|
// cancellation and deadlines while preserving the shortcut's resolved identity.
|
|
// A nil callCtx falls back to the RuntimeContext's command-wide context.
|
|
func (ctx *RuntimeContext) DoAPIWithContext(callCtx context.Context, req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
|
if callCtx == nil {
|
|
callCtx = ctx.ctx
|
|
}
|
|
ac, err := ctx.getAPIClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if optFn := cmdutil.ShortcutHeaderOpts(callCtx); optFn != nil {
|
|
opts = append(opts, optFn)
|
|
}
|
|
return ac.DoSDKRequest(callCtx, 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...)...)
|
|
}
|
|
|
|
// DoAPIJSONTyped issues a larkcore.ApiReq request, parses the JSON response,
|
|
// and classifies failures into typed errs.* errors via ClassifyAPIResponse,
|
|
// which lifts MissingScopes / ConsoleURL / Identity onto the typed error at the
|
|
// source and merges the response log id into the returned data. A transport /
|
|
// auth error from the client boundary is already typed and passes through
|
|
// unchanged; a non-zero API code is classified with subtype / code / log_id.
|
|
func (ctx *RuntimeContext) DoAPIJSONTyped(method, apiPath string, query larkcore.QueryParams, body any) (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, typedOrInternal(err)
|
|
}
|
|
return ctx.ClassifyAPIResponse(resp)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// StartSpinner shows a braille spinner with elapsed time on stderr for a slow
|
|
// operation, until the returned stop() runs. It is a no-op unless stderr is an
|
|
// interactive terminal, so pipes / CI / captured output emit nothing and stdout
|
|
// (JSON/pretty) is never polluted — hence it is shown in JSON mode too. Call
|
|
// stop() before printing the result; stop() is safe to call multiple times
|
|
// (e.g. `defer stop()` plus an explicit call on the success path).
|
|
func (ctx *RuntimeContext) StartSpinner(label string) func() {
|
|
io := ctx.IO()
|
|
if io == nil {
|
|
return func() {}
|
|
}
|
|
return output.StartSpinner(io.ErrOut, io.StderrIsTerminal, label)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// WrapInputStatErrorTyped wraps a FileIO.Stat/Open error for input file
|
|
// validation, returning a typed validation error 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 WrapInputStatErrorTyped(err error, readMsg ...string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if errors.Is(err, fileio.ErrPathValidation) {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).
|
|
WithCause(err)
|
|
}
|
|
msg := "cannot read file"
|
|
if len(readMsg) > 0 && readMsg[0] != "" {
|
|
msg = readMsg[0]
|
|
}
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).
|
|
WithCause(err)
|
|
}
|
|
|
|
// WrapSaveErrorTyped maps a FileIO.Save error to typed validation/internal errors.
|
|
// Non-path failures always emit the canonical "internal" wire type; call sites
|
|
// migrating from a custom category (e.g. "io", "api_error") change their
|
|
// envelope's type field.
|
|
func WrapSaveErrorTyped(err error) error {
|
|
return WrapSaveErrorTypedForFlag(err, "")
|
|
}
|
|
|
|
// WrapSaveErrorTypedForFlag is the parameter-aware form used by commands whose
|
|
// output path is a user-facing flag. Keeping the flag at the call site avoids
|
|
// attributing download/save errors from unrelated commands to --output-path.
|
|
func WrapSaveErrorTypedForFlag(err error, param string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if _, ok := errs.ProblemOf(err); ok {
|
|
return err
|
|
}
|
|
var me *fileio.MkdirError
|
|
switch {
|
|
case errors.Is(err, fileio.ErrPathValidation):
|
|
verr := errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err)
|
|
if param != "" {
|
|
verr = verr.WithParam(param)
|
|
}
|
|
return verr.WithCause(err)
|
|
case errors.As(err, &me):
|
|
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).
|
|
WithCause(err)
|
|
default:
|
|
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).
|
|
WithCause(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 ──
|
|
|
|
// newEmitter creates an output emitter configured for the active runtime.
|
|
func (ctx *RuntimeContext) newEmitter() *output.Emitter {
|
|
streams := ctx.IO()
|
|
return output.NewEmitter(output.EmitterConfig{
|
|
Out: streams.Out,
|
|
ErrOut: streams.ErrOut,
|
|
CommandPath: ctx.Cmd.CommandPath(),
|
|
Identity: string(ctx.As()),
|
|
ColorEnabled: streams.OutIsTerminal,
|
|
NoticeProvider: output.GetNotice,
|
|
})
|
|
}
|
|
|
|
// handleEmitterError records output failures without corrupting the command data stream.
|
|
func (ctx *RuntimeContext) handleEmitterError(err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
var cs *errs.ContentSafetyError
|
|
if ctx.JqExpr != "" && !errors.As(err, &cs) {
|
|
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
|
|
}
|
|
ctx.outputErrOnce.Do(func() { ctx.outputErr = err })
|
|
}
|
|
|
|
// OutputError returns the first deferred output failure captured by Out,
|
|
// OutRaw, or OutFormat. Commands that create local artifacts can use it to
|
|
// roll those artifacts back before returning the final command error.
|
|
func (ctx *RuntimeContext) OutputError() error {
|
|
return ctx.outputErr
|
|
}
|
|
|
|
// wrapLegacyPrettyRenderer adapts a writer-only renderer to the current output contract.
|
|
func wrapLegacyPrettyRenderer(prettyFn func(w io.Writer)) output.PrettyRenderer {
|
|
if prettyFn == nil {
|
|
return nil
|
|
}
|
|
return func(w io.Writer, _ bool) error {
|
|
prettyFn(w)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Out prints a success JSON envelope to stdout.
|
|
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
|
|
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
|
|
Format: "",
|
|
Raw: false,
|
|
JQ: ctx.JqExpr,
|
|
Meta: meta,
|
|
}))
|
|
}
|
|
|
|
// 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.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
|
|
Format: "",
|
|
Raw: true,
|
|
JQ: ctx.JqExpr,
|
|
Meta: meta,
|
|
}))
|
|
}
|
|
|
|
// OutPartialFailure writes an ok:false multi-status result envelope to stdout
|
|
// and returns the partial-failure exit signal. Use it for batch operations
|
|
// where some items failed but the per-item outcomes are the primary output:
|
|
// the full result (summary + per-item statuses) stays machine-readable on
|
|
// stdout, the process exits non-zero, and nothing is written to stderr.
|
|
//
|
|
// It is the typed alternative to `Out(...)` + `output.ErrBare(...)` — the
|
|
// envelope's ok field honestly reports failure instead of a misleading
|
|
// ok:true, and the exit signal is distinct from ErrBare (the
|
|
// stdout-carries-the-answer silent-exit signal).
|
|
func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta) error {
|
|
ctx.handleEmitterError(ctx.newEmitter().PartialFailure(data, output.EmitOptions{
|
|
Format: "",
|
|
Raw: false,
|
|
JQ: ctx.JqExpr,
|
|
Meta: meta,
|
|
}))
|
|
if ctx.outputErr != nil {
|
|
return ctx.outputErr
|
|
}
|
|
return output.PartialFailure(output.ExitAPI)
|
|
}
|
|
|
|
// OutFormat prints output based on --format flag.
|
|
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
|
|
// When JqExpr is set, envelope filtering takes precedence over format.
|
|
// The Emitter handles content safety scanning for every format.
|
|
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
|
|
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
|
|
Format: ctx.Format,
|
|
Raw: false,
|
|
JQ: ctx.JqExpr,
|
|
Meta: meta,
|
|
Pretty: wrapLegacyPrettyRenderer(prettyFn),
|
|
}))
|
|
}
|
|
|
|
// 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.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
|
|
Format: ctx.Format,
|
|
Raw: true,
|
|
JQ: ctx.JqExpr,
|
|
Meta: meta,
|
|
Pretty: wrapLegacyPrettyRenderer(prettyFn),
|
|
}))
|
|
}
|
|
|
|
// ── 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. 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) {
|
|
return err
|
|
}
|
|
permErr.WithMissingScopes(requiredScopes...)
|
|
if permErr.Identity == "" {
|
|
permErr.WithIdentity(string(core.AsUser))
|
|
}
|
|
// Discard any generic classifier hint now that this shortcut has supplied
|
|
// the authoritative scope facts. The root presenter will rebuild recovery
|
|
// from those facts for its own command surface.
|
|
permErr.Hint = ""
|
|
return err
|
|
}
|
|
|
|
// ── 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)
|
|
}
|
|
|
|
// MountWithContext registers a shortcut while preserving the caller-provided context.
|
|
func (s Shortcut) MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
|
if s.Execute != nil || s.typed != nil {
|
|
s.mountDeclarative(ctx, parent, f)
|
|
}
|
|
}
|
|
|
|
// mountDeclarative builds and registers the Cobra command described by a Shortcut.
|
|
func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
|
shortcut := s
|
|
if shortcut.typed != nil {
|
|
if err := validateTypedFlagMountPlan(shortcut.typed, shortcut.PrintFlagSchema != nil, typedRisk(shortcut.Risk)); err != nil {
|
|
panic(fmt.Sprintf("typed shortcut %s %s: %v", shortcut.Service, shortcut.Command, err))
|
|
}
|
|
}
|
|
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 {
|
|
if handled, err := runShortcutFlagSchema(cmd, f, &shortcut); handled {
|
|
return err
|
|
}
|
|
if shortcut.typed != nil {
|
|
return runTypedMountedShortcut(cmd, f, &shortcut, botOnly)
|
|
}
|
|
return runShortcut(cmd, f, &shortcut, botOnly)
|
|
},
|
|
}
|
|
if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil {
|
|
onInvoke := shortcut.OnInvoke
|
|
relaxRequiredForSchema := shortcut.PrintFlagSchema != nil
|
|
// PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses:
|
|
// - OnInvoke: fire a side effect (e.g. a deprecation notice) that must
|
|
// surface even when the call later fails on a missing required flag.
|
|
// - --print-schema: pure local introspection; relax the required-flag
|
|
// gate so callers don't fill in unrelated flags just to ask for a
|
|
// schema (clearing the annotation here is the supported opt-out).
|
|
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
|
|
if onInvoke != nil {
|
|
onInvoke()
|
|
}
|
|
if relaxRequiredForSchema {
|
|
if want, _ := c.Flags().GetBool("print-schema"); want {
|
|
c.Flags().VisitAll(func(fl *pflag.Flag) {
|
|
// Schema inspection is a local metadata operation. Bypass both
|
|
// individual required flags and Cobra groups so business inputs
|
|
// are never needed merely to inspect a complex field.
|
|
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
|
delete(fl.Annotations, "cobra_annotation_one_required")
|
|
delete(fl.Annotations, "cobra_annotation_mutually_exclusive")
|
|
delete(fl.Annotations, "cobra_annotation_required_if_others_set")
|
|
})
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
cmdmeta.SetSource(cmd, cmdmeta.SourceShortcut, false)
|
|
cmdmeta.SetAffordanceRef(cmd, shortcut.Service, shortcut.Command)
|
|
cmdmeta.SetDeclaredScopes(cmd, map[string][]string{
|
|
"user": shortcut.DeclaredScopesForIdentity("user"),
|
|
"bot": shortcut.DeclaredScopesForIdentity("bot"),
|
|
})
|
|
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 {
|
|
var typedSnapshot typedMountSnapshot
|
|
if shortcut.typed != nil {
|
|
typedSnapshot = captureTypedMountSnapshot(cmd)
|
|
}
|
|
shortcut.PostMount(cmd)
|
|
if shortcut.typed != nil {
|
|
if err := validateTypedPostMount(cmd, typedSnapshot); err != nil {
|
|
panic(fmt.Sprintf("typed shortcut %s %s: %v", shortcut.Service, shortcut.Command, err))
|
|
}
|
|
}
|
|
}
|
|
installFlagAliases(cmd, shortcut.Flags)
|
|
if shortcut.typed != nil {
|
|
installTypedAnnotations(cmd, shortcut.typed)
|
|
installTypedHelp(cmd, shortcut.typed)
|
|
}
|
|
}
|
|
|
|
func runTypedMountedShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
|
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
|
if dryRun {
|
|
as, err := resolveShortcutIdentity(cmd, f, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
config, err := f.Config()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rctx := newDryRunRuntimeContext(cmd, f, s, config, as, botOnly)
|
|
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
|
|
return err
|
|
}
|
|
return runTypedShortcut(f, rctx, s)
|
|
}
|
|
as, err := resolveShortcutIdentity(cmd, f, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
config, err := f.Config()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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 := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
|
|
return err
|
|
}
|
|
return runTypedShortcut(f, rctx, s)
|
|
}
|
|
func installTypedAnnotations(cmd *cobra.Command, command *compiledCommand) {
|
|
for _, field := range command.fields {
|
|
if field.cli.Deprecated != "" {
|
|
_ = cmd.Flags().MarkDeprecated(field.name, field.cli.Deprecated)
|
|
}
|
|
for _, alias := range field.cli.Aliases {
|
|
if alias.Mode != typedAliasIndependent || !alias.Deprecated {
|
|
continue
|
|
}
|
|
_ = cmd.Flags().MarkDeprecated(alias.Name, "use --"+field.name+" instead")
|
|
}
|
|
}
|
|
for _, relation := range command.relations {
|
|
if relation.stage != typedStageSourcePreRun || relation.presence != typedPresenceExplicit {
|
|
continue
|
|
}
|
|
names := make([]string, 0, len(relation.fields))
|
|
for _, index := range relation.fields {
|
|
names = append(names, command.fields[index].name)
|
|
}
|
|
switch relation.kind {
|
|
case typedRelationExactlyOne:
|
|
cmd.MarkFlagsOneRequired(names...)
|
|
cmd.MarkFlagsMutuallyExclusive(names...)
|
|
case typedRelationAtLeastOne:
|
|
cmd.MarkFlagsOneRequired(names...)
|
|
case typedRelationCoOccur:
|
|
cmd.MarkFlagsRequiredTogether(names...)
|
|
case typedRelationConflicts:
|
|
cmd.MarkFlagsMutuallyExclusive(names...)
|
|
}
|
|
}
|
|
}
|
|
|
|
func runShortcutFlagSchema(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (bool, error) {
|
|
if s.PrintFlagSchema == nil {
|
|
return false, nil
|
|
}
|
|
want, _ := cmd.Flags().GetBool("print-schema")
|
|
if !want {
|
|
return false, nil
|
|
}
|
|
flagName, _ := cmd.Flags().GetString("flag-name")
|
|
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
|
|
if err != nil {
|
|
// PrintFlagSchema implementations may return bare errors; wrap those so
|
|
// this agent-facing local introspection path stays machine-readable.
|
|
if !errs.IsTyped(err) {
|
|
err = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
|
|
}
|
|
return true, err
|
|
}
|
|
if len(out) > 0 {
|
|
fmt.Fprintln(f.IOStreams.Out, string(out))
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// runShortcut is the execution pipeline for a declarative shortcut.
|
|
// Each step is a clear phase: identity → config → scopes → runtime →
|
|
// canonical validation → 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 s.Normalize != nil {
|
|
// Normalize is opt-in and consumes resolved values. Shortcuts without a
|
|
// normalizer retain the established enum-before-input execution order.
|
|
if err := resolveInputFlags(rctx, s.Flags); err != nil {
|
|
return attributeAliasValidationError(rctx, err)
|
|
}
|
|
flagContext := rctx.FlagContext()
|
|
if err := s.Normalize(rctx.ctx, flagContext); err != nil {
|
|
return attributeAliasValidationError(rctx, err)
|
|
}
|
|
}
|
|
if err := validateEnumFlags(rctx, s.Flags); err != nil {
|
|
return attributeAliasValidationError(rctx, err)
|
|
}
|
|
if s.Normalize == nil {
|
|
if err := resolveInputFlags(rctx, s.Flags); err != nil {
|
|
return attributeAliasValidationError(rctx, 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 attributeAliasValidationError(rctx, 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 attributeAliasValidationError(rctx, err)
|
|
}
|
|
return rctx.outputErr
|
|
}
|
|
|
|
// resolveShortcutIdentity selects the effective identity for a shortcut invocation.
|
|
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
|
|
}
|
|
|
|
// checkShortcutScopes verifies that the selected identity satisfies the shortcut scope contract.
|
|
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...)
|
|
}
|
|
|
|
// newRuntimeContext assembles the dependencies and resolved identity for one shortcut execution.
|
|
func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) (*RuntimeContext, error) {
|
|
rctx := newRuntimeContextBase(cmd, f, s, config, as, botOnly)
|
|
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
|
|
return rctx, nil
|
|
}
|
|
|
|
func newDryRunRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) *RuntimeContext {
|
|
rctx := newRuntimeContextBase(cmd, f, s, config, as, botOnly)
|
|
rctx.offline = true
|
|
return rctx
|
|
}
|
|
|
|
func newRuntimeContextBase(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) *RuntimeContext {
|
|
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.declaredScopes = s.DeclaredScopesForIdentity(string(rctx.As()))
|
|
applyJSONShorthand(cmd, s)
|
|
rctx.Format = rctx.Str("format")
|
|
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
|
return rctx
|
|
}
|
|
|
|
// StripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
|
|
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
|
|
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
|
|
// head of a JSON payload makes json.Unmarshal fail with "invalid character 'ï'".
|
|
// Some editors and exporters add it silently. Only a leading BOM is removed; interior
|
|
// occurrences are left untouched.
|
|
func StripUTF8BOM(s string) string {
|
|
return strings.TrimPrefix(s, "\uFEFF")
|
|
}
|
|
|
|
// InputResolvedFromSource reports whether the named flag's value was loaded
|
|
// from an external source (@file or stdin `-`) by resolveInputFlags, as
|
|
// opposed to typed inline on the command line. Domain guards that apply
|
|
// shape heuristics to inline values ("this looks like a file path — did you
|
|
// forget the @?") must skip resolved values: their content was already read
|
|
// from the right place and may legitimately look like anything, including a
|
|
// path. Without this bit such a guard re-rejects correct @file / stdin
|
|
// invocations, because by the time Validate runs both arrive as plain text.
|
|
func (ctx *RuntimeContext) InputResolvedFromSource(name string) bool {
|
|
return ctx.inputResolved[name]
|
|
}
|
|
|
|
// MarkInputResolved records that the named flag now holds content read from an
|
|
// external source rather than a value typed inline, setting the bit
|
|
// InputResolvedFromSource reports. resolveInputFlags calls it for @file and
|
|
// stdin; a domain calls it when it resolves a source itself (sheets reads the
|
|
// path a +csv-put caller passed as --file), so the shape guards downstream
|
|
// treat the result as content, exactly as they would for --csv @<path>.
|
|
func (ctx *RuntimeContext) MarkInputResolved(name string) {
|
|
if ctx.inputResolved == nil {
|
|
ctx.inputResolved = map[string]bool{}
|
|
}
|
|
ctx.inputResolved[name] = true
|
|
}
|
|
|
|
// 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 {
|
|
for _, fl := range flags {
|
|
if len(fl.Input) == 0 {
|
|
continue
|
|
}
|
|
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
|
|
if err != nil {
|
|
return ValidationErrorf("--%s: Input is only supported for string flags", fl.Name).
|
|
WithParam("--" + fl.Name)
|
|
}
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
|
|
// stdin: -
|
|
if raw == "-" {
|
|
if !slices.Contains(fl.Input, Stdin) {
|
|
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
|
|
WithParam("--" + fl.Name)
|
|
}
|
|
// A process has a single stdin, so we reject a second Input flag
|
|
// trying to use `-` after the first one has already consumed it.
|
|
if rctx.stdinConsumed {
|
|
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
|
WithParam("--"+fl.Name).
|
|
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others inline or as @file with a relative path under the current directory (e.g. --%s @./payload.json)", fl.Name)
|
|
}
|
|
rctx.stdinConsumed = true
|
|
data, err := io.ReadAll(rctx.IO().In)
|
|
if err != nil {
|
|
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
|
|
WithParam("--" + fl.Name).
|
|
WithCause(err)
|
|
}
|
|
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
|
|
// cell or break JSON parsing downstream.
|
|
rctx.Cmd.Flags().Set(fl.Name, StripUTF8BOM(string(data)))
|
|
rctx.MarkInputResolved(fl.Name)
|
|
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 ValidationErrorf("--%s does not support file input (@path)", fl.Name).
|
|
WithParam("--" + fl.Name)
|
|
}
|
|
path := strings.TrimSpace(raw[1:])
|
|
if path == "" {
|
|
return ValidationErrorf("--%s: file path cannot be empty after @", fl.Name).
|
|
WithParam("--" + fl.Name)
|
|
}
|
|
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
|
if err != nil {
|
|
verr := ValidationErrorf("--%s: %v", fl.Name, err).
|
|
WithParam("--" + fl.Name).
|
|
WithCause(err)
|
|
if slices.Contains(fl.Input, Stdin) {
|
|
// Rejected @file paths are usually absolute (temp files under
|
|
// /tmp). Steer toward stdin rather than cd / copying the file
|
|
// into the project tree.
|
|
verr = verr.WithHint("this flag also reads stdin: pipe the file contents into this command and pass --%s -", fl.Name)
|
|
}
|
|
return verr
|
|
}
|
|
// strip a leading UTF-8 BOM so it
|
|
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
|
rctx.Cmd.Flags().Set(fl.Name, StripUTF8BOM(string(data)))
|
|
rctx.MarkInputResolved(fl.Name)
|
|
continue
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateEnumFlags rejects shortcut flag values outside their declared enum choices.
|
|
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 ValidationErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", ")).
|
|
WithParam("--" + fl.Name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleShortcutDryRun renders a shortcut plan without sending its API requests.
|
|
func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
|
|
if s.DryRun == nil {
|
|
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
|
|
WithParam("--dry-run")
|
|
}
|
|
dryResult := s.DryRun(rctx.ctx, rctx)
|
|
if dryResult != nil {
|
|
// Same data.context contract as the service/api dry-run paths.
|
|
dryResult.Context(rctx.Config.AppID, rctx.UserOpenId())
|
|
}
|
|
return cmdutil.WriteDryRun(dryResult, cmdutil.DryRunOutputOptions{
|
|
Format: rctx.Format,
|
|
JqExpr: rctx.JqExpr,
|
|
CommandPath: rctx.Cmd.CommandPath(),
|
|
Identity: rctx.As(),
|
|
Out: f.IOStreams.Out,
|
|
ErrOut: f.IOStreams.ErrOut,
|
|
})
|
|
}
|
|
|
|
// rejectPositionalArgs returns a cobra.PositionalArgs that rejects any
|
|
// positional arguments. It returns a plain cobra usage error; the root
|
|
// handler classifies it into the typed validation envelope (exit 2), the
|
|
// same path as other cobra usage failures.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// registerShortcutFlags adds the shortcut's declared flags to its Cobra command.
|
|
func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
|
|
registerShortcutFlagsWithContext(context.Background(), cmd, f, s)
|
|
}
|
|
|
|
// shortcutDeclaresJSONFlag reports whether the shortcut itself declares a flag
|
|
// named "json" in its Flags list (custom semantics, e.g. event +subscribe's
|
|
// pretty-print switch or base +record-search's request-body payload).
|
|
// Framework-injected flags never appear in s.Flags, so this cleanly separates
|
|
// "self-declared json" from "injected shorthand".
|
|
func shortcutDeclaresJSONFlag(s *Shortcut) bool {
|
|
for _, fl := range s.Flags {
|
|
if fl.Name == "json" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
|
|
// "json": a self-declared format supports it only when its Enum lists "json";
|
|
// a framework-injected default format (no format entry in s.Flags) always does.
|
|
func shortcutFormatSupportsJSON(s *Shortcut) bool {
|
|
for _, fl := range s.Flags {
|
|
if fl.Name == "format" {
|
|
return slices.Contains(fl.Enum, "json")
|
|
}
|
|
}
|
|
return true // framework-injected: json (default) | pretty | table | ndjson | csv
|
|
}
|
|
|
|
// ensureJSONShorthand registers --json as a shorthand for --format json when:
|
|
// 1. the command has a format flag (self-declared or framework-injected), AND
|
|
// 2. that format supports "json" (see shortcutFormatSupportsJSON), AND
|
|
// 3. no flag named "json" is registered yet — pflag panics on duplicate
|
|
// registration, and commands that declare their own --json (event
|
|
// +subscribe, base +record-search/-get) keep their custom semantics.
|
|
func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
|
// A shortcut that declares its own "json" flag defines custom semantics
|
|
// (e.g. pretty-print switch, request-body payload) — never a shorthand.
|
|
if shortcutDeclaresJSONFlag(s) {
|
|
return
|
|
}
|
|
if cmd.Flags().Lookup("format") == nil {
|
|
return
|
|
}
|
|
if !shortcutFormatSupportsJSON(s) {
|
|
return
|
|
}
|
|
// Safety net: pflag panics on duplicate registration.
|
|
if cmd.Flags().Lookup("json") != nil {
|
|
return
|
|
}
|
|
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
|
}
|
|
|
|
// applyJSONShorthand folds the injected --json shorthand into the format flag
|
|
// itself, before rctx.Format caches it — so both the cached value (OutFormat,
|
|
// ValidateJqFlags, dry-run) and later runtime.Str("format") reads observe
|
|
// "json". An explicitly passed --format always wins over the shorthand (the
|
|
// shorthand only fills in when the user did not choose a format). Shortcuts
|
|
// that declare their own "json" flag keep its custom semantics untouched.
|
|
func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
|
if shortcutDeclaresJSONFlag(s) {
|
|
return
|
|
}
|
|
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
|
|
return
|
|
}
|
|
if set, _ := cmd.Flags().GetBool("json"); set {
|
|
_ = cmd.Flags().Set("format", "json")
|
|
}
|
|
}
|
|
|
|
// registerShortcutFlagsWithContext registers shortcut flags using the supplied command context.
|
|
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) {
|
|
// "- reads stdin" intentionally avoids implying each flag has
|
|
// its own stdin: a process has a single stdin, so at most one
|
|
// flag per call may use "-" (the rest must use @file). The old
|
|
// per-flag "- for stdin" wording led AI agents to write
|
|
// `--a - <x --b - <y`, where the second `<` silently clobbers
|
|
// the first and `--a` reads the wrong payload.
|
|
hints = append(hints, "- reads stdin (one flag per call; use @file for others)")
|
|
}
|
|
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 "float64":
|
|
var d float64
|
|
fmt.Sscanf(fl.Default, "%g", &d)
|
|
cmd.Flags().Float64(fl.Name, d, desc)
|
|
case "int_array":
|
|
var values []int
|
|
if fl.Default != "" {
|
|
_ = json.Unmarshal([]byte(fl.Default), &values)
|
|
}
|
|
cmd.Flags().IntSlice(fl.Name, values, desc)
|
|
case "string_array":
|
|
var values []string
|
|
if fl.Default != "" {
|
|
_ = json.Unmarshal([]byte(fl.Default), &values)
|
|
}
|
|
cmd.Flags().StringArray(fl.Name, values, desc)
|
|
case "string_slice":
|
|
var values []string
|
|
if fl.Default != "" {
|
|
_ = json.Unmarshal([]byte(fl.Default), &values)
|
|
}
|
|
cmd.Flags().StringSlice(fl.Name, values, 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
|
|
})
|
|
}
|
|
ensureJSONShorthand(cmd, s)
|
|
if s.Risk == "high-risk-write" {
|
|
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
|
}
|
|
if s.PrintFlagSchema != nil {
|
|
// Guard against a shortcut that already declares these reserved
|
|
// introspection flags: pflag panics on a duplicate registration.
|
|
// Mirrors the Lookup guard on --format above.
|
|
if cmd.Flags().Lookup("print-schema") == nil {
|
|
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
|
|
}
|
|
if cmd.Flags().Lookup("flag-name") == nil {
|
|
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
|
|
}
|
|
}
|
|
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
|
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
|
}
|