Compare commits

...

12 Commits

Author SHA1 Message Date
zhaojunlin.0405
7f23badfea Merge remote-tracking branch 'origin/main' into feat/embedder-public-apis 2026-07-14 14:21:12 +08:00
zhaojunlin.0405
097bccf060 docs(extension): disclose need_user_authorization hint gap; preserve cobra-usage cause
Address CodeRabbit review on PR #1865:
- envelope.DispatchError godoc no longer claims blanket byte-identical parity; it discloses that the official dispatcher folds the need_user_authorization scope hint via a Factory embedders lack, so a direct call omits only that hint.
- dispatch.go cobra-usage fallback now chains .WithCause(err), matching the internal-error branch and the repo guideline (keeps errors.Is/Unwrap working); envelope bytes and exit code are unchanged (Cause is not serialized).
2026-07-14 14:21:00 +08:00
zhaojunlin.0405
d418e25281 fix(registry): reword apimeta package doc to satisfy compiler-directive lint
The bare mention of go:embed at the start of a doc comment line tripped gocheckcompilerdirectives (it parsed as a malformed //go:embed directive). Reword so the token appears mid-sentence.
2026-07-14 14:21:00 +08:00
zhaojunlin.0405
e32fdb8c7f fix(credential): move OpenIDVerified to end of Account to avoid unkeyed-literal break
Inserting an exported field mid-struct breaks downstream unkeyed struct literals (a Go API compatibility break for module embedders): a positional value intended for SupportedIdentities would land on the new bool field. Append OpenIDVerified after SupportedIdentities so existing field positions are preserved; keyed literals were already unaffected.
2026-07-14 14:21:00 +08:00
zhaojunlin.0405
2aab338374 feat(extension): add public envelope.DispatchError facade with golden parity tests 2026-07-13 20:24:06 +08:00
zhaojunlin.0405
b9d4dce92a feat(output): add DispatchError and route handleRootError through it 2026-07-13 20:24:06 +08:00
zhaojunlin.0405
9ead47e525 refactor(output): extract renderTypedEnvelope from WriteTypedErrorEnvelope 2026-07-13 20:24:06 +08:00
zhaojunlin.0405
15d0254b91 feat(credential): skip startup user_info verification for asserted OpenID 2026-07-13 20:24:06 +08:00
zhaojunlin.0405
a5c2a45ecf feat(credential): add asserted OpenID injection via LARKSUITE_CLI_USER_OPEN_ID 2026-07-13 20:24:06 +08:00
zhaojunlin.0405
fc630ba8f9 fix(registry): clone injected metadata; tighten SetEmbedded godoc 2026-07-13 20:24:06 +08:00
zhaojunlin.0405
70cee047a2 feat(extension): add apimeta.SetEmbedded public injection API 2026-07-13 20:24:06 +08:00
zhaojunlin.0405
0d3ecdb990 feat(registry): add SetEmbeddedMeta with parse-order guard 2026-07-13 20:24:06 +08:00
17 changed files with 737 additions and 126 deletions

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"errors"
"fmt"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/envelope"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// TestDispatchErrorGoldenParity asserts the public envelope.DispatchError
// triple is byte-identical to what the real root dispatcher
// (handleRootError) writes to stderr, with the same exit code, for every
// error class (spec G1-G6). Comparison happens at the dispatch boundary:
// need_user_authorization hint folding runs before dispatch and is not part
// of the public contract, so cases here must not depend on it.
func TestDispatchErrorGoldenParity(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
err error
}{
{"G1_typed_validation", errs.NewValidationError(errs.SubtypeInvalidArgument, "missing --id")},
{"G2_typed_extension_fields", &errs.PermissionError{
Problem: errs.Problem{
Category: errs.CategoryAuthorization,
Subtype: errs.SubtypePermissionDenied,
Code: 99991679,
Message: "missing required scopes",
Hint: "re-auth with the listed scopes",
},
MissingScopes: []string{"im:message", "docs:doc"},
Identity: "user",
}},
{"G3_confirmation_required", errs.NewConfirmationRequiredError(
"high-risk-write", "drive +delete", "drive +delete requires confirmation")},
{"G4_partial_failure", output.PartialFailure(1)},
{"G5_cobra_usage", fmt.Errorf(`required flag(s) "values" not set`)},
{"G6_leaked_untyped", errors.New("boom")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
realExit := handleRootError(f, tc.err)
env, code, has := envelope.DispatchError(tc.err, string(f.ResolvedIdentity))
if code != realExit {
t.Errorf("exit code: public %d, real dispatcher %d", code, realExit)
}
if has != (errOut.Len() > 0) {
t.Errorf("hasEnvelope=%v but real stderr len=%d", has, errOut.Len())
}
if !bytes.Equal(env, errOut.Bytes()) {
t.Errorf("envelope bytes differ\npublic: %s\nreal: %s", env, errOut.Bytes())
}
})
}
}

View File

@@ -5,7 +5,6 @@ package cmd
import (
"context"
"errors"
"fmt"
"os"
"sort"
@@ -229,109 +228,25 @@ func configureFlagCompletions(args []string) {
}
// handleRootError dispatches a command error to the appropriate handler
// and returns the process exit code.
//
// Dispatch order:
// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError):
// render via the typed envelope writer, which lifts extension fields
// (missing_scopes, console_url, challenge_url, ...) to the top level.
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are
// constructed typed at their origin (internal/auth, internal/core), so the
// dispatcher no longer promotes any legacy shape here.
// 2. PartialFailure / BareError signals: the result envelope is already on
// stdout; honor the exit code and write nothing to stderr.
// 3. Residual cobra usage errors (missing required flag, unknown command,
// argument validation): typed as an invalid_argument envelope (exit 2),
// matching the explicit flag/subcommand guards. Flag parse errors are
// already typed upstream by the root FlagErrorFunc.
// and returns the process exit code. The classification itself (typed
// envelope vs. PartialFailure/Bare signal vs. cobra-usage vs. leaked-untyped)
// lives in output.DispatchError so the public extension/envelope facade
// shares the exact same branches — see its doc comment for the dispatch
// order.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
// When the typed error is a need_user_authorization signal, fold in the
// current command's declared scopes as a Hint so the user/AI sees the
// concrete scope(s) to re-auth with. The hint is computed on the fly from
// local shortcut/service metadata — it never depends on server state.
// current command's declared scopes as a Hint. The hint depends on the
// Factory, so it stays in the cmd layer — it is applied before dispatch
// and is not part of the public DispatchError contract.
if !errs.IsRaw(err) {
applyNeedAuthorizationHint(f, err)
}
// Staged dispatch: capture the typed exit code BEFORE attempting the
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
// (partial-write still returns true) so the exit code we read here is
// preserved even if stderr is torn — torn stderr must not downgrade
// typed exits 3/4/6/10 to the plain "Error:" path with exit 1.
// WriteTypedErrorEnvelope still returns false when err carries no
// Problem; in that case we fall through to the signal / plain-text paths.
typedExit := output.ExitCodeOf(err)
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
return typedExit
env, code, has := output.DispatchError(err, string(f.ResolvedIdentity))
if has {
// Best-effort write: a torn stderr must not downgrade the typed exit.
_, _ = f.IOStreams.ErrOut.Write(env)
}
// Partial-failure (batch / multi-status): the ok:false result envelope is
// already on stdout; set the exit code and write nothing to stderr.
var pfErr *output.PartialFailureError
if errors.As(err, &pfErr) {
return pfErr.Code
}
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`):
// stdout already carries the result; honor the requested exit code and
// write nothing to stderr.
var bareErr *output.BareError
if errors.As(err, &bareErr) {
return bareErr.Code
}
// Errors reaching here are untyped: every RunE returns a typed errs.* error
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
// is either a cobra usage mistake (missing required flag, unknown command,
// wrong arg count), which cobra surfaces as a plain error identified by its
// stable text — the same external contract unknownFlagName relies on — or an
// untyped error that leaked past the typed boundary. Classify the former as
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
// internal fault (exit 5) rather than blaming the user's input. The message
// is preserved either way, and the typed envelope still carries any pending
// deprecation notice.
var fallback error
if isCobraUsageError(err) {
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
} else {
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
}
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
return output.ExitCodeOf(fallback)
}
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
// not a typed value we can match on, so the dispatcher recognizes them by text;
// this is the same external contract unknownFlagName already depends on. A
// residual error matching none of these has leaked the typed boundary and is
// treated as an internal fault, not a user error.
var cobraUsageErrorMarkers = []string{
"unknown command ",
"unknown flag: ",
"unknown shorthand",
"required flag(s) ",
"flag needs an argument",
"bad flag syntax:",
"no such flag ",
"invalid argument ",
"arg(s), ", // accepts / requires N arg(s), received / only received M
}
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
// identified by the stable error text of the pinned cobra version.
func isCobraUsageError(err error) bool {
msg := err.Error()
for _, m := range cobraUsageErrorMarkers {
if strings.Contains(msg, m) {
return true
}
}
return false
return code
}
// installUnknownSubcommandGuard replaces cobra's silent help fallback on

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package apimeta lets Go module integrators install embedded API metadata
// for this process, equivalent to the meta_data.json that official lark-cli
// builds compile in via go:embed.
//
// Binaries built from the bare Go module embed only an empty metadata stub,
// so schema resolution and generated service commands have nothing to work
// with offline. Integrators that ship their own metadata (typically embedded
// into their binary with their own go:embed directive) call SetEmbedded at
// process start to make the CLI treat those bytes as the compiled-in
// metadata, with no behavioral difference from an official build.
package apimeta
import "github.com/larksuite/cli/internal/registry"
// ErrAlreadyLoaded reports that SetEmbedded was called after the embedded
// metadata had already been parsed, so the injection was rejected. Use
// errors.Is(err, ErrAlreadyLoaded) to detect it.
var ErrAlreadyLoaded = registry.ErrMetaAlreadyLoaded
// SetEmbedded installs data as this process's embedded API metadata.
//
// It must be called before any registry consumption — cmd.Build, cmd.Execute,
// schema resolution, or scope discovery — typically at the top of main() or
// from an init() function — early enough provided no other init in the process
// has already triggered registry consumption. Calling it after the metadata has
// been parsed returns ErrAlreadyLoaded.
//
// data must parse as lark-cli API metadata and declare at least one service;
// otherwise an error is returned and the existing state (the empty stub, or
// the compiled-in metadata of an official build) is left unchanged.
//
// data is copied on success; the caller may reuse or modify the buffer afterwards.
//
// Calling SetEmbedded multiple times before the first parse is allowed: the last
// successful call wins, mirroring ordinary Go process-init trust — whoever
// links code into the binary controls its metadata.
func SetEmbedded(data []byte) error {
return registry.SetEmbeddedMeta(data)
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apimeta_test
import (
"errors"
"testing"
"github.com/larksuite/cli/extension/apimeta"
"github.com/larksuite/cli/internal/registry"
)
// A1: 非法数据经门脸返回错误,且不是 ErrAlreadyLoaded
func TestSetEmbedded_InvalidDataRejected(t *testing.T) {
err := apimeta.SetEmbedded([]byte(`{"broken`))
if err == nil {
t.Fatalf("SetEmbedded(invalid) = nil, want error")
}
if errors.Is(err, apimeta.ErrAlreadyLoaded) {
t.Fatalf("SetEmbedded(invalid) = ErrAlreadyLoaded, want parse error")
}
}
// A2: sentinel 与 internal/registry 同值errors.Is 语义成立
func TestErrAlreadyLoaded_AliasesRegistrySentinel(t *testing.T) {
if !errors.Is(apimeta.ErrAlreadyLoaded, registry.ErrMetaAlreadyLoaded) {
t.Fatalf("apimeta.ErrAlreadyLoaded is not registry.ErrMetaAlreadyLoaded")
}
}

View File

@@ -89,6 +89,11 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
}
}
if openID := os.Getenv(envvars.CliUserOpenID); openID != "" && hasUAT {
acct.OpenID = openID
acct.OpenIDVerified = true
}
return acct, nil
}

View File

@@ -280,3 +280,47 @@ func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
}
}
func TestResolveAccountOpenIDAssertedWithUAT(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliUserAccessToken, "u-token")
t.Setenv(envvars.CliUserOpenID, "ou_injected")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount: %v", err)
}
if acct.OpenID != "ou_injected" {
t.Errorf("OpenID = %q, want %q", acct.OpenID, "ou_injected")
}
if !acct.OpenIDVerified {
t.Error("OpenIDVerified = false, want true")
}
}
func TestResolveAccountOpenIDIgnoredWithoutUAT(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliTenantAccessToken, "t-token") // 仅 TAT无 UAT
t.Setenv(envvars.CliUserOpenID, "ou_injected")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount: %v", err)
}
if acct.OpenID != "" || acct.OpenIDVerified {
t.Errorf("OpenID/OpenIDVerified = %q/%v, want empty/false (no UAT)", acct.OpenID, acct.OpenIDVerified)
}
}
func TestResolveAccountNoOpenIDEnvUnchanged(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliUserAccessToken, "u-token")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount: %v", err)
}
if acct.OpenID != "" || acct.OpenIDVerified {
t.Errorf("OpenID/OpenIDVerified = %q/%v, want empty/false (env not set)", acct.OpenID, acct.OpenIDVerified)
}
}

View File

@@ -46,13 +46,28 @@ func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
// Account holds resolved app credentials and configuration.
type Account struct {
AppID string
AppSecret string // real app secret; empty or NoAppSecret means unavailable
Brand Brand // BrandLark or BrandFeishu
DefaultAs Identity // IdentityUser / IdentityBot / IdentityAuto; empty = not set
ProfileName string
OpenID string // optional; if UAT is available, API result takes precedence
AppID string
AppSecret string // real app secret; empty or NoAppSecret means unavailable
Brand Brand // BrandLark or BrandFeishu
DefaultAs Identity // IdentityUser / IdentityBot / IdentityAuto; empty = not set
ProfileName string
// OpenID is the optional user open_id hint. If a UAT is available, the
// user_info API result takes precedence unless OpenIDVerified is set.
OpenID string
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
// OpenIDVerified marks OpenID as an identity assertion the provider has
// already verified. When true and OpenID is non-empty, the CLI skips the
// startup user_info verification call and uses OpenID as-is wherever the
// resolved user identity is consumed (identity selection, whoami display,
// stored-token lookup). The CLI does NOT re-verify the asserted value:
// a mismatched assertion is the responsibility of the integrator that
// manages the token supply, and whoami will display the injected,
// unverified value on this path. Setting OpenIDVerified with an empty
// OpenID is treated as unasserted and falls back to normal verification.
//
// Appended at the end of the struct so adding it does not shift the
// positions of existing fields for unkeyed struct literals.
OpenIDVerified bool
}
// Token holds a resolved access token and optional metadata.

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package envelope exposes lark-cli's error-dispatch decision to embedders.
//
// Integrators that call cmd.Build and drive Execute themselves must render
// errors like the official binary so agents can parse stderr uniformly.
// DispatchError is the same function the official root dispatcher consumes,
// so error classification, exit codes, and envelope bytes match for every
// error the dispatcher receives.
//
// One narrow exception: the official root dispatcher enriches a
// need_user_authorization error with the current command's declared scopes
// (via a cmdutil.Factory it holds) before calling DispatchError. That
// enrichment depends on command context an embedder does not have, so a
// direct DispatchError call on a raw need_user_authorization error produces
// an otherwise-identical envelope without the folded-in scope hint. All other
// error categories are unaffected.
package envelope
import "github.com/larksuite/cli/internal/output"
// DispatchError classifies err exactly like lark-cli's own root dispatcher
// and returns the stderr envelope bytes (if any) together with the process
// exit code. identity is the resolved identity string ("user", "bot", or ""
// to omit the field). Typical embedder epilogue:
//
// env, code, has := envelope.DispatchError(err, "user")
// if has {
// _, _ = os.Stderr.Write(env)
// }
// os.Exit(code)
func DispatchError(err error, identity string) (envelope []byte, exitCode int, hasEnvelope bool) {
return output.DispatchError(err, identity)
}

View File

@@ -181,7 +181,13 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er
if acct != nil {
internal := convertAccount(acct)
source := extensionTokenSource{provider: prov}
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
if acct.OpenIDVerified && acct.OpenID != "" {
// Provider asserted a verified identity (e.g. env provider's
// LARKSUITE_CLI_USER_OPEN_ID): skip the startup user_info
// verification. UserOpenId is already populated by
// convertAccount; the token's validity is still enforced by
// the first real API call.
} else if err := p.enrichUserInfo(ctx, internal, source); err != nil {
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
}

View File

@@ -8,6 +8,7 @@ import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
@@ -491,3 +492,99 @@ func TestActiveExtensionProviderName_SkipsNilProvider(t *testing.T) {
t.Errorf("got %q, want empty string", name)
}
}
func TestCredentialProvider_AssertedOpenIDSkipsUserInfo(t *testing.T) {
httpClientCalls := 0
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: "asserted",
account: &extcred.Account{
AppID: "cli_a", Brand: extcred.BrandFeishu,
OpenID: "ou_injected", OpenIDVerified: true,
},
token: &extcred.Token{Value: "u-token", Source: "test"},
}},
nil, nil,
func() (*http.Client, error) {
httpClientCalls++
return nil, errors.New("user_info must not be fetched on asserted path")
},
)
acct, err := cp.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount: %v", err)
}
if httpClientCalls != 0 {
t.Fatalf("httpClient() called %d times, want 0 (verification skipped)", httpClientCalls)
}
if acct.UserOpenId != "ou_injected" {
t.Errorf("UserOpenId = %q, want %q", acct.UserOpenId, "ou_injected")
}
if acct.UserName != "" {
t.Errorf("UserName = %q, want empty on asserted path", acct.UserName)
}
}
func TestCredentialProvider_VerifiedFlagWithoutOpenIDFallsBack(t *testing.T) {
// 非法组合OpenIDVerified=true 但 OpenID 空 → 视为未断言,照常尝试验证。
httpClientCalls := 0
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: "misconfigured",
account: &extcred.Account{
AppID: "cli_a", Brand: extcred.BrandFeishu, OpenIDVerified: true,
},
token: &extcred.Token{Value: "u-token", Source: "test"},
}},
nil, nil,
func() (*http.Client, error) {
httpClientCalls++
return nil, errors.New("fail verification")
},
)
acct, err := cp.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount: %v", err)
}
if httpClientCalls == 0 {
t.Fatal("httpClient() not called, want verification attempt (invalid combination must fall back)")
}
// enrich 失败 → 现有防御逻辑清空未验证身份
if acct.UserOpenId != "" {
t.Errorf("UserOpenId = %q, want empty after failed verification", acct.UserOpenId)
}
}
func TestCredentialProvider_UnassertedOpenIDStillOverridden(t *testing.T) {
// 现有语义保持OpenID 非空但未断言 → 有 UAT 时 API 结果覆盖。
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open-apis/authen/v1/user_info" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(`{"code":0,"msg":"ok","data":{"open_id":"ou_from_api","name":"API User"}}`))
}))
defer srv.Close()
// Endpoint injection mirrors tat_fetch_test.go's TestFetchTAT_ContextCanceled:
// there is no env-var endpoint override in this repo, so we rewrite the
// request host to the test server via a custom RoundTripper instead.
hc := &http.Client{Transport: &urlRewriteRT{base: srv.URL}}
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: "hint-only",
account: &extcred.Account{
AppID: "cli_a", Brand: extcred.BrandFeishu, OpenID: "ou_hint",
},
token: &extcred.Token{Value: "u-token", Source: "test"},
}},
nil, nil,
func() (*http.Client, error) { return hc, nil },
)
acct, err := cp.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount: %v", err)
}
if acct.UserOpenId != "ou_from_api" {
t.Errorf("UserOpenId = %q, want %q (API result takes precedence)", acct.UserOpenId, "ou_from_api")
}
}

View File

@@ -11,6 +11,7 @@ const (
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
CliUserOpenID = "LARKSUITE_CLI_USER_OPEN_ID"
// Sidecar proxy (auth proxy mode)
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"errors"
"strings"
"github.com/larksuite/cli/errs"
)
// DispatchError classifies err exactly like the root command dispatcher and
// returns the rendered stderr envelope (if any) together with the process
// exit code. It is the single classification path shared by lark-cli's own
// root dispatcher and the public extension/envelope facade, so embedders that
// drive Execute themselves render errors byte-identically to the official
// binary.
//
// Classification, in order:
//
// 1. nil → (nil, 0, false).
// 2. Typed errs.* carrying a Problem → (envelope, ExitCodeOf(err), true).
// If envelope encoding fails the error falls through to branch 4 so
// stderr is never blank.
// 3. *PartialFailureError / *BareError → (nil, signal code, false): the
// result envelope is already on stdout; write nothing to stderr.
// 4. Remaining untyped errors: cobra usage text → invalid_argument envelope
// with exit 2; anything else leaked past the typed boundary → internal
// envelope with exit 5.
func DispatchError(err error, identity string) (envelope []byte, exitCode int, hasEnvelope bool) {
if err == nil {
return nil, 0, false
}
typedExit := ExitCodeOf(err)
if env, ok := renderTypedEnvelope(err, identity); ok {
return env, typedExit, true
}
var pfErr *PartialFailureError
if errors.As(err, &pfErr) {
return nil, pfErr.Code, false
}
var bareErr *BareError
if errors.As(err, &bareErr) {
return nil, bareErr.Code, false
}
var fallback error
if isCobraUsageError(err) {
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
} else {
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
}
env, ok := renderTypedEnvelope(fallback, identity)
if !ok {
return nil, ExitCodeOf(fallback), false
}
return env, ExitCodeOf(fallback), true
}
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
// not a typed value we can match on, so the dispatcher recognizes them by text;
// this is the same external contract unknownFlagName already depends on. A
// residual error matching none of these has leaked the typed boundary and is
// treated as an internal fault, not a user error.
var cobraUsageErrorMarkers = []string{
"unknown command ",
"unknown flag: ",
"unknown shorthand",
"required flag(s) ",
"flag needs an argument",
"bad flag syntax:",
"no such flag ",
"invalid argument ",
"arg(s), ", // accepts / requires N arg(s), received / only received M
}
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
// identified by the stable error text of the pinned cobra version.
func isCobraUsageError(err error) bool {
msg := err.Error()
for _, m := range cobraUsageErrorMarkers {
if strings.Contains(msg, m) {
return true
}
}
return false
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"encoding/json"
"errors"
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestDispatchErrorNil(t *testing.T) {
env, code, has := DispatchError(nil, "user")
if env != nil || code != 0 || has {
t.Fatalf("DispatchError(nil) = (%v, %d, %v), want (nil, 0, false)", env, code, has)
}
}
func TestDispatchErrorTyped(t *testing.T) {
err := errs.NewValidationError(errs.SubtypeInvalidArgument, "missing --id")
env, code, has := DispatchError(err, "user")
if !has || code != ExitCodeOf(err) {
t.Fatalf("has=%v code=%d, want true / %d", has, code, ExitCodeOf(err))
}
var parsed map[string]any
if jsonErr := json.Unmarshal(env, &parsed); jsonErr != nil {
t.Fatalf("envelope not valid JSON: %v", jsonErr)
}
if parsed["ok"] != false || parsed["identity"] != "user" {
t.Errorf("envelope ok/identity = %v/%v", parsed["ok"], parsed["identity"])
}
}
func TestDispatchErrorPartialFailure(t *testing.T) {
env, code, has := DispatchError(PartialFailure(1), "user")
if env != nil || code != 1 || has {
t.Fatalf("got (%v, %d, %v), want (nil, 1, false)", env, code, has)
}
}
func TestDispatchErrorBare(t *testing.T) {
env, code, has := DispatchError(ErrBare(3), "user")
if env != nil || code != 3 || has {
t.Fatalf("got (%v, %d, %v), want (nil, 3, false)", env, code, has)
}
}
func TestDispatchErrorCobraUsage(t *testing.T) {
env, code, has := DispatchError(fmt.Errorf(`required flag(s) "values" not set`), "user")
if !has || code != 2 {
t.Fatalf("has=%v code=%d, want true / 2", has, code)
}
var parsed map[string]any
_ = json.Unmarshal(env, &parsed)
errObj := parsed["error"].(map[string]any)
if errObj["subtype"] != "invalid_argument" {
t.Errorf("subtype = %v, want invalid_argument", errObj["subtype"])
}
}
func TestDispatchErrorLeakedUntyped(t *testing.T) {
env, code, has := DispatchError(errors.New("boom"), "bot")
if !has || code != 5 {
t.Fatalf("has=%v code=%d, want true / 5", has, code)
}
var parsed map[string]any
_ = json.Unmarshal(env, &parsed)
if parsed["identity"] != "bot" {
t.Errorf("identity = %v, want bot", parsed["identity"])
}
}
func TestDispatchErrorEmptyIdentityOmitted(t *testing.T) {
env, _, _ := DispatchError(errs.NewValidationError(errs.SubtypeInvalidArgument, "x"), "")
var parsed map[string]any
_ = json.Unmarshal(env, &parsed)
if _, present := parsed["identity"]; present {
t.Error("identity field present, want omitted for empty identity")
}
}

View File

@@ -34,6 +34,30 @@ func PartialFailure(code int) *PartialFailureError {
return &PartialFailureError{Code: code}
}
// renderTypedEnvelope serializes the typed-error envelope for err. It returns
// (nil, false) when err carries no Problem or when JSON encoding fails — the
// dispatcher then falls through to its signal / usage-error branches.
func renderTypedEnvelope(err error, identity string) ([]byte, bool) {
typed, ok := errs.UnwrapTypedError(err)
if !ok {
return nil, false
}
env := typedEnvelope{
OK: false,
Identity: identity,
Error: typed,
Notice: GetNotice(),
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if encErr := enc.Encode(env); encErr != nil {
return nil, false
}
return buf.Bytes(), true
}
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
// Each typed error owns its wire shape via its own struct tags: Problem fields
// are promoted to the top level through embedding, and extension fields
@@ -56,30 +80,11 @@ func PartialFailure(code int) *PartialFailureError {
// Returns false only when err carries no Problem (the dispatcher then handles
// it via its signal / usage-error branches) or when JSON encoding itself failed.
func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
typed, ok := errs.UnwrapTypedError(err)
b, ok := renderTypedEnvelope(err, identity)
if !ok {
return false
}
env := typedEnvelope{
OK: false,
Identity: identity,
Error: typed,
Notice: GetNotice(),
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if encErr := enc.Encode(env); encErr != nil {
// Encoding failed — emit nothing here; the dispatcher's fall-through
// branches still surface the error, so stderr is never blank.
return false
}
// Best-effort write. Partial-write does not downgrade the success status:
// the dispatcher has already captured ExitCodeOf(err) before calling us,
// and a torn stderr is preferable to falling through to the plain
// "Error:" path with exit 1.
_, _ = w.Write(buf.Bytes())
_, _ = w.Write(b)
return true
}

View File

@@ -4,8 +4,11 @@
package registry
import (
"bytes"
"embed"
"encoding/json"
"errors"
"fmt"
"math"
"path/filepath"
"runtime"
@@ -31,6 +34,43 @@ var (
embeddedParseOnce sync.Once
)
// ErrMetaAlreadyLoaded is returned by SetEmbeddedMeta when the embedded
// metadata has already been parsed; injection must happen before any
// registry consumption. extension/apimeta re-exports it as ErrAlreadyLoaded.
var ErrMetaAlreadyLoaded = errors.New("embedded api metadata already parsed")
var (
// embeddedInjectMu serializes SetEmbeddedMeta against the first parse so
// check-then-write and mark-then-parse never interleave: an injection
// either fully lands before the parse or fails with ErrMetaAlreadyLoaded.
embeddedInjectMu sync.Mutex
embeddedParsed bool // set inside parseEmbedded's Once body
)
// SetEmbeddedMeta validates data and installs it as this process's embedded
// API metadata — the same variable go:embed fills in official builds, so every
// downstream consumer (schema, command generation, scope discovery, cache
// overlay version gating) behaves exactly as an official build would.
//
// It is the internal engine of extension/apimeta.SetEmbedded; see that
// package for the public contract.
func SetEmbeddedMeta(data []byte) error {
reg, err := meta.Parse(data) // validate before write; meta.Parse(nil/empty) returns a zero Registry with no error
if err != nil {
return fmt.Errorf("invalid api metadata: %w", err)
}
if len(reg.Services) == 0 {
return errors.New("api metadata contains no services")
}
embeddedInjectMu.Lock()
defer embeddedInjectMu.Unlock()
if embeddedParsed {
return ErrMetaAlreadyLoaded
}
embeddedMetaJSON = bytes.Clone(data)
return nil
}
// parseEmbedded decodes the embedded meta_data.json into the typed model exactly
// once. It is the single parse of the embedded bytes: both the overlay-free
// envelope path (EmbeddedServicesTyped) and the merged command/scope path
@@ -38,6 +78,9 @@ var (
// twice and no map round-trip is needed downstream.
func parseEmbedded() {
embeddedParseOnce.Do(func() {
embeddedInjectMu.Lock()
embeddedParsed = true
embeddedInjectMu.Unlock()
reg, _ := meta.Parse(embeddedMetaJSON)
embeddedVersion = reg.Version
embeddedServices = reg.Services

View File

@@ -0,0 +1,126 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
)
const injectValidMetaJSON = `{"version":"9.9.9","services":[{"name":"testsvc","title":"Test Service","resources":{}}]}`
// resetInjectState resets package state for injection tests and restores the
// original embedded bytes afterwards (same save/restore pattern as
// catalog_test.go). resetInit() itself clears embeddedParsed.
func resetInjectState(t *testing.T) {
t.Helper()
orig := embeddedMetaJSON
resetInit()
embeddedServices = nil
embeddedServicesByName = nil
t.Cleanup(func() {
resetInit()
embeddedServices = nil
embeddedServicesByName = nil
embeddedMetaJSON = orig
})
}
// R1: parse 前注入合法 meta → 生效version 基线更新
func TestSetEmbeddedMeta_InjectBeforeParse(t *testing.T) {
resetInjectState(t)
if err := SetEmbeddedMeta([]byte(injectValidMetaJSON)); err != nil {
t.Fatalf("SetEmbeddedMeta() = %v, want nil", err)
}
svcs := EmbeddedServicesTyped()
if len(svcs) != 1 || svcs[0].Name != "testsvc" {
t.Fatalf("EmbeddedServicesTyped() = %+v, want single service testsvc", svcs)
}
if embeddedVersion != "9.9.9" { // R7: overlay 门禁的比较基线来源正确
t.Fatalf("embeddedVersion = %q, want %q", embeddedVersion, "9.9.9")
}
}
// R1b: parse 前多次注入 → 后写覆盖注入者赢spec §3.2 末行)
func TestSetEmbeddedMeta_LastWriteWinsBeforeParse(t *testing.T) {
resetInjectState(t)
first := `{"version":"1.0.0","services":[{"name":"firstsvc","resources":{}}]}`
if err := SetEmbeddedMeta([]byte(first)); err != nil {
t.Fatalf("SetEmbeddedMeta(first) = %v, want nil", err)
}
if err := SetEmbeddedMeta([]byte(injectValidMetaJSON)); err != nil {
t.Fatalf("SetEmbeddedMeta(second) = %v, want nil", err)
}
svcs := EmbeddedServicesTyped()
if len(svcs) != 1 || svcs[0].Name != "testsvc" {
t.Fatalf("EmbeddedServicesTyped() = %+v, want last-injected testsvc", svcs)
}
}
// R2: 注入后 SchemaCatalog 走 embedded 快路径
func TestSetEmbeddedMeta_SchemaCatalogUsesEmbedded(t *testing.T) {
resetInjectState(t)
if err := SetEmbeddedMeta([]byte(injectValidMetaJSON)); err != nil {
t.Fatalf("SetEmbeddedMeta() = %v, want nil", err)
}
cat := SchemaCatalog()
if cat.Source() != apicatalog.SourceEmbedded {
t.Fatalf("SchemaCatalog().Source() = %q, want %q", cat.Source(), apicatalog.SourceEmbedded)
}
if _, ok := cat.Service("testsvc"); !ok {
t.Fatalf("SchemaCatalog() missing injected service testsvc")
}
}
// R3: 非法 JSON 拒绝且状态不变
func TestSetEmbeddedMeta_InvalidJSONRejected(t *testing.T) {
resetInjectState(t)
before := embeddedMetaJSON
err := SetEmbeddedMeta([]byte(`{"broken`))
if err == nil || !strings.Contains(err.Error(), "invalid api metadata") {
t.Fatalf("SetEmbeddedMeta(invalid) = %v, want invalid api metadata error", err)
}
if string(embeddedMetaJSON) != string(before) {
t.Fatalf("embeddedMetaJSON mutated on rejected input")
}
}
// R4: 合法 JSON 但 services 为空 → 拒绝且状态不变
func TestSetEmbeddedMeta_EmptyServicesRejected(t *testing.T) {
resetInjectState(t)
before := embeddedMetaJSON
for _, in := range []string{`{}`, `{"version":"1.0.0","services":[]}`} {
err := SetEmbeddedMeta([]byte(in))
if err == nil || !strings.Contains(err.Error(), "api metadata contains no services") {
t.Fatalf("SetEmbeddedMeta(%q) = %v, want no-services error", in, err)
}
}
if string(embeddedMetaJSON) != string(before) {
t.Fatalf("embeddedMetaJSON mutated on rejected input")
}
}
// R5: 首次 parse 之后注入 → ErrMetaAlreadyLoaded
func TestSetEmbeddedMeta_AfterParseRejected(t *testing.T) {
resetInjectState(t)
_ = EmbeddedServicesTyped() // 触发首次 parse
err := SetEmbeddedMeta([]byte(injectValidMetaJSON))
if !errors.Is(err, ErrMetaAlreadyLoaded) {
t.Fatalf("SetEmbeddedMeta(after parse) = %v, want ErrMetaAlreadyLoaded", err)
}
}
// R6: nil / 空字节 → 恒走 services 为空路径meta.Parse 空输入返回零值无错误)
func TestSetEmbeddedMeta_NilAndEmptyRejected(t *testing.T) {
resetInjectState(t)
for _, in := range [][]byte{nil, {}} {
err := SetEmbeddedMeta(in)
if err == nil || !strings.Contains(err.Error(), "api metadata contains no services") {
t.Fatalf("SetEmbeddedMeta(len=%d) = %v, want no-services error", len(in), err)
}
}
}

View File

@@ -32,6 +32,9 @@ func resetInit() {
waitBackgroundRefresh()
initOnce = sync.Once{}
embeddedParseOnce = sync.Once{}
embeddedInjectMu.Lock()
embeddedParsed = false
embeddedInjectMu.Unlock()
servicesTypedOnce = sync.Once{}
servicesTyped = nil
mergedServices = make(map[string]meta.Service)