From ef62370a5e3eeab488a11f812746ae0bc35bdad6 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:27:19 +0800 Subject: [PATCH 01/18] feat: add URL rewrite extension contract --- extension/transport/registry_test.go | 33 ++++++ extension/transport/types.go | 14 +++ internal/urlrewrite/rewrite.go | 73 ++++++++++++ internal/urlrewrite/rewrite_test.go | 159 +++++++++++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 internal/urlrewrite/rewrite.go create mode 100644 internal/urlrewrite/rewrite_test.go diff --git a/extension/transport/registry_test.go b/extension/transport/registry_test.go index 836cbca14f..eff628207c 100644 --- a/extension/transport/registry_test.go +++ b/extension/transport/registry_test.go @@ -22,6 +22,19 @@ type stubProvider struct { func (s *stubProvider) Name() string { return s.name } func (s *stubProvider) ResolveInterceptor(context.Context) Interceptor { return &stubInterceptor{} } +type stubURLRewriterProvider struct { + stubProvider + rewriter URLRewriter +} + +func (s *stubURLRewriterProvider) ResolveURLRewriter(context.Context) URLRewriter { + return s.rewriter +} + +type stubURLRewriter func(string) string + +func (f stubURLRewriter) RewriteURL(rawURL string) string { return f(rawURL) } + func TestGetProvider_NilByDefault(t *testing.T) { mu.Lock() provider = nil @@ -75,3 +88,23 @@ func TestResolveInterceptor_ReturnsNonNil(t *testing.T) { t.Fatal("expected non-nil Interceptor") } } + +func TestURLRewriterProviderIsOptional(t *testing.T) { + previous := GetProvider() + Register(nil) + t.Cleanup(func() { Register(previous) }) + + p := &stubURLRewriterProvider{ + stubProvider: stubProvider{name: "rewrite"}, + rewriter: stubURLRewriter(func(string) string { return "https://mirror.example.test" }), + } + Register(p) + + rewriterProvider, ok := GetProvider().(URLRewriterProvider) + if !ok { + t.Fatalf("registered provider does not implement URLRewriterProvider") + } + if got := rewriterProvider.ResolveURLRewriter(context.Background()).RewriteURL("https://source.example.test"); got != "https://mirror.example.test" { + t.Fatalf("RewriteURL() = %q", got) + } +} diff --git a/extension/transport/types.go b/extension/transport/types.go index 61c6f04420..6614a50f79 100644 --- a/extension/transport/types.go +++ b/extension/transport/types.go @@ -15,6 +15,20 @@ type Provider interface { ResolveInterceptor(ctx context.Context) Interceptor } +// URLRewriter maps a URL to the URL that lark-cli should use. +// Returning the input unchanged means no rewrite. +type URLRewriter interface { + RewriteURL(rawURL string) string +} + +// URLRewriterProvider optionally supplies URL rewriting in addition to the +// existing request interceptor. Providers that do not implement this interface +// retain their existing behavior. +type URLRewriterProvider interface { + Provider + ResolveURLRewriter(ctx context.Context) URLRewriter +} + // RequestClass describes the trust boundary of an outbound HTTP request. // Platform requests target endpoints owned by the CLI's endpoint resolver; // external requests target user-provided, pre-signed, CDN, registry, or other diff --git a/internal/urlrewrite/rewrite.go b/internal/urlrewrite/rewrite.go new file mode 100644 index 0000000000..d1025b2628 --- /dev/null +++ b/internal/urlrewrite/rewrite.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package urlrewrite resolves and applies the optional URL rewrite extension. +package urlrewrite + +import ( + "context" + "net/url" + + "github.com/larksuite/cli/errs" + exttransport "github.com/larksuite/cli/extension/transport" +) + +// Resolver holds the URL rewriter resolved for one caller. +// +// A nil rewriter is an identity resolver. Resolve once when a caller needs to +// apply the same extension to multiple URLs. +type Resolver struct { + rewriter exttransport.URLRewriter +} + +// Resolve resolves the URL rewriter from the registered transport provider. +// Providers that do not implement URLRewriterProvider, and providers that +// return a nil rewriter, produce an identity resolver. +func Resolve(ctx context.Context) *Resolver { + p, ok := exttransport.GetProvider().(exttransport.URLRewriterProvider) + if !ok { + return &Resolver{} + } + return &Resolver{rewriter: p.ResolveURLRewriter(ctx)} +} + +// Rewrite resolves the registered URL rewriter and applies it to rawURL. +func Rewrite(ctx context.Context, rawURL string) (string, error) { + return Resolve(ctx).Rewrite(rawURL) +} + +// Rewrite applies the resolved URL rewriter to rawURL. Identity results are +// returned verbatim. Changed values must be absolute HTTP(S) URLs without +// userinfo. +func (r *Resolver) Rewrite(rawURL string) (string, error) { + if r == nil || r.rewriter == nil { + return rawURL, nil + } + + rewritten := r.rewriter.RewriteURL(rawURL) + if rewritten == rawURL { + return rawURL, nil + } + if !validURL(rewritten) { + return "", invalidRewriteError() + } + return rewritten, nil +} + +func validURL(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + return u.IsAbs() && + (u.Scheme == "http" || u.Scheme == "https") && + u.Host != "" && + u.User == nil +} + +func invalidRewriteError() *errs.ConfigError { + return errs.NewConfigError( + errs.SubtypeInvalidConfig, + "registered URL rewriter returned an invalid absolute HTTP(S) URL", + ).WithHint("check the URL rewrite configuration") +} diff --git a/internal/urlrewrite/rewrite_test.go b/internal/urlrewrite/rewrite_test.go new file mode 100644 index 0000000000..56da8b4387 --- /dev/null +++ b/internal/urlrewrite/rewrite_test.go @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package urlrewrite + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + + "github.com/larksuite/cli/errs" + exttransport "github.com/larksuite/cli/extension/transport" + "github.com/larksuite/cli/internal/output" +) + +type testProvider struct { + rewriter exttransport.URLRewriter +} + +func (testProvider) Name() string { return "test" } + +func (testProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { return nil } + +type legacyProvider struct{} + +func (legacyProvider) Name() string { return "legacy" } + +func (legacyProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { return nil } + +type rewriteFunc func(string) string + +func (f rewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } + +func (p testProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { return p.rewriter } + +func withProvider(t *testing.T, provider exttransport.Provider) { + t.Helper() + previous := exttransport.GetProvider() + exttransport.Register(provider) + t.Cleanup(func() { exttransport.Register(previous) }) +} + +func TestRewriteIdentityWithoutURLRewriter(t *testing.T) { + raw := "https://example.test/a%2Fb?x=1+2&x=3" + + for _, tc := range []struct { + name string + provider exttransport.Provider + }{ + {name: "no provider"}, + {name: "legacy provider", provider: legacyProvider{}}, + {name: "nil rewriter", provider: testProvider{}}, + } { + t.Run(tc.name, func(t *testing.T) { + withProvider(t, tc.provider) + + got, err := Rewrite(context.Background(), raw) + if err != nil { + t.Fatalf("Rewrite() error = %v", err) + } + if got != raw { + t.Fatalf("Rewrite() = %q, want exact %q", got, raw) + } + }) + } +} + +func TestRewriteIdentityPreservesRawURL(t *testing.T) { + raw := "not a valid URL %2F?x=1+2&x=3" + withProvider(t, testProvider{rewriter: rewriteFunc(func(string) string { return raw })}) + + got, err := Rewrite(context.Background(), raw) + if err != nil { + t.Fatalf("Rewrite() error = %v", err) + } + if got != raw { + t.Fatalf("Rewrite() = %q, want exact %q", got, raw) + } +} + +func TestRewriteAcceptsChangedAbsoluteHTTPURL(t *testing.T) { + const want = "http://mirror.example.test:8080/a%2Fb?x=1+2&x=3#fragment" + withProvider(t, testProvider{rewriter: rewriteFunc(func(string) string { return want })}) + + got, err := Rewrite(context.Background(), "https://source.example.test/path") + if err != nil { + t.Fatalf("Rewrite() error = %v", err) + } + if got != want { + t.Fatalf("Rewrite() = %q, want %q", got, want) + } +} + +func TestRewriteRejectsInvalidChangedURL(t *testing.T) { + for _, rewritten := range []string{ + "", + "/relative/path", + "ftp://example.test/path", + "https://user:password@example.test/path", + "https://", + "https://example.test/%zz", + } { + t.Run(rewritten, func(t *testing.T) { + withProvider(t, testProvider{rewriter: rewriteFunc(func(string) string { return rewritten })}) + + _, err := Rewrite(context.Background(), "https://source.example.test/path?secret=one") + var configErr *errs.ConfigError + if !errors.As(err, &configErr) { + t.Fatalf("Rewrite() error = %T %v, want *errs.ConfigError", err, err) + } + if configErr.Subtype != errs.SubtypeInvalidConfig { + t.Errorf("subtype = %q, want %q", configErr.Subtype, errs.SubtypeInvalidConfig) + } + if configErr.Message != "registered URL rewriter returned an invalid absolute HTTP(S) URL" { + t.Errorf("message = %q", configErr.Message) + } + if configErr.Hint != "check the URL rewrite configuration" { + t.Errorf("hint = %q", configErr.Hint) + } + if got := output.ExitCodeOf(err); got != output.ExitAuth { + t.Errorf("exit code = %d, want %d", got, output.ExitAuth) + } + for _, sensitive := range []string{"source.example.test", "secret=one", rewritten} { + if sensitive != "" && strings.Contains(err.Error(), sensitive) { + t.Errorf("error leaked %q: %v", sensitive, err) + } + } + }) + } +} + +func TestResolverRewriteConcurrent(t *testing.T) { + withProvider(t, testProvider{rewriter: rewriteFunc(func(rawURL string) string { + return strings.Replace(rawURL, "source.example.test", "mirror.example.test", 1) + })}) + + resolver := Resolve(context.Background()) + const workers = 32 + var group sync.WaitGroup + group.Add(workers) + for range workers { + go func() { + defer group.Done() + got, err := resolver.Rewrite("https://source.example.test/path") + if err != nil { + t.Errorf("Rewrite() error = %v", err) + } + if got != "https://mirror.example.test/path" { + t.Errorf("Rewrite() = %q", got) + } + }() + } + group.Wait() +} + +var _ exttransport.Provider = testProvider{} +var _ exttransport.URLRewriterProvider = testProvider{} From 1b3806f81196c84adc02cd7ace24ce5bd6cc2dab Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:34:26 +0800 Subject: [PATCH 02/18] feat: apply URL rewriting in transport --- internal/transport/extension.go | 73 +++++++-- internal/transport/extension_test.go | 226 +++++++++++++++++++++++++++ internal/urlrewrite/rewrite.go | 9 +- internal/urlrewrite/rewrite_test.go | 17 ++ 4 files changed, 307 insertions(+), 18 deletions(-) diff --git a/internal/transport/extension.go b/internal/transport/extension.go index 0243e6ea0c..a73dacd5fb 100644 --- a/internal/transport/extension.go +++ b/internal/transport/extension.go @@ -6,8 +6,11 @@ package transport import ( "context" "net/http" + "net/url" + "github.com/larksuite/cli/errs" exttransport "github.com/larksuite/cli/extension/transport" + "github.com/larksuite/cli/internal/urlrewrite" ) var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil) @@ -15,6 +18,7 @@ var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil) type resolvedExtension struct { provider exttransport.Provider interceptor exttransport.Interceptor + rewriter *urlrewrite.Resolver } func resolveExtension() *resolvedExtension { @@ -22,11 +26,18 @@ func resolveExtension() *resolvedExtension { if p == nil { return nil } - interceptor := p.ResolveInterceptor(context.Background()) - if interceptor == nil { + + extension := &resolvedExtension{ + provider: p, + interceptor: p.ResolveInterceptor(context.Background()), + } + if _, ok := p.(exttransport.URLRewriterProvider); ok { + extension.rewriter = urlrewrite.ResolveProvider(context.Background(), p) + } + if extension.interceptor == nil && extension.rewriter == nil { return nil } - return &resolvedExtension{provider: p, interceptor: interceptor} + return extension } func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.RequestClass, enforceScope bool) http.RoundTripper { @@ -36,16 +47,25 @@ func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.Requ if e == nil { return base } - if enforceScope { + interceptor := e.interceptor + if enforceScope && interceptor != nil { if scoped, ok := e.provider.(exttransport.ScopedProvider); ok && !scoped.SupportsRequestClass(class) { - return base + interceptor = nil } } - return &ExtensionMiddleware{Base: base, Ext: e.interceptor, ExtName: e.provider.Name()} + if interceptor == nil && e.rewriter == nil { + return base + } + return &ExtensionMiddleware{ + Base: base, + Ext: interceptor, + ExtName: e.provider.Name(), + rewriter: e.rewriter, + } } -// ExtensionMiddleware wraps the built-in transport chain with extension -// pre/post hooks. The built-in chain always executes unless an +// ExtensionMiddleware wraps the built-in transport chain with URL rewriting +// and extension pre/post hooks. The built-in chain always executes unless an // exttransport.AbortableInterceptor rejects the request. // // The original request context is restored after the pre hook to prevent an @@ -54,9 +74,10 @@ func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.Requ // request object. The body remains shared; interceptors that consume it must // restore it before returning. type ExtensionMiddleware struct { - Base http.RoundTripper - Ext exttransport.Interceptor - ExtName string + Base http.RoundTripper + Ext exttransport.Interceptor + ExtName string + rewriter *urlrewrite.Resolver } // BaseRoundTripper returns the wrapped built-in transport chain. @@ -80,15 +101,34 @@ func (m *ExtensionMiddleware) WithBaseRoundTripper(base http.RoundTripper) http. func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) { origCtx := req.Context() req = req.Clone(origCtx) + if m.rewriter != nil { + rewritten, err := m.rewriter.Rewrite(req.URL.String()) + if err != nil { + return nil, err + } + if rewritten != req.URL.String() { + // Resolver validates changed URLs with url.Parse before returning. + rewrittenURL, err := url.Parse(rewritten) + if err != nil { + return nil, errs.NewInternalError( + errs.SubtypeUnknown, + "URL rewrite validation returned an unparsable URL", + ) + } + req.URL = rewrittenURL + } + } var ( post func(*http.Response, error) abortErr error ) - if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok { - post, abortErr = a.PreRoundTripE(req) - } else { - post = m.Ext.PreRoundTrip(req) + if m.Ext != nil { + if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok { + post, abortErr = a.PreRoundTripE(req) + } else { + post = m.Ext.PreRoundTrip(req) + } } if abortErr != nil { if post != nil { @@ -106,8 +146,7 @@ func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, erro } // WrapWithExtension wraps base with the currently registered transport -// extension. With no registered provider or no resolved interceptor, base is -// returned unchanged. +// extension. With no registered provider, base is returned unchanged. func WrapWithExtension(base http.RoundTripper) http.RoundTripper { return resolveExtension().wrap(base, "", false) } diff --git a/internal/transport/extension_test.go b/internal/transport/extension_test.go index 5b83f7f69a..c2d8212db5 100644 --- a/internal/transport/extension_test.go +++ b/internal/transport/extension_test.go @@ -35,6 +35,32 @@ func (p testProvider) ResolveInterceptor(context.Context) exttransport.Intercept return p.interceptor } +type rewriteTestProvider struct { + testProvider + rewriter exttransport.URLRewriter + rewriteCalls *int +} + +func (p rewriteTestProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { + if p.rewriteCalls != nil { + *p.rewriteCalls++ + } + return p.rewriter +} + +type scopedRewriteTestProvider struct { + rewriteTestProvider + supported exttransport.RequestClass +} + +func (p scopedRewriteTestProvider) SupportsRequestClass(class exttransport.RequestClass) bool { + return class == p.supported +} + +type rewriteFunc func(string) string + +func (f rewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } + type scopedTestProvider struct { testProvider supported exttransport.RequestClass @@ -54,6 +80,15 @@ func (i *testHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Respo return nil } +type urlCapturingInterceptor struct { + url string +} + +func (i *urlCapturingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) { + i.url = req.URL.String() + return nil +} + type abortingTestInterceptor struct { reason error post func(*http.Response, error) @@ -170,6 +205,197 @@ func TestHTTPPolicyRouterResolvesProviderOnce(t *testing.T) { } } +func TestHTTPPolicyRouterRewriteOnlyProviderDoesNotMutateCaller(t *testing.T) { + previousProvider := exttransport.GetProvider() + exttransport.Register(rewriteTestProvider{ + testProvider: testProvider{}, + rewriter: rewriteFunc(func(rawURL string) string { + return strings.Replace(rawURL, "source.example.test", "mirror.example.test", 1) + }), + }) + t.Cleanup(func() { exttransport.Register(previousProvider) }) + + var baseURL string + router := NewHTTPPolicyRouter( + roundTripFunc(func(req *http.Request) (*http.Response, error) { + baseURL = req.URL.String() + return noContentResponse(req), nil + }), + roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("external policy selected for explicit platform request") + return nil, nil + }), + ) + + const originalURL = "https://source.example.test/open-apis/test?x=1" + req, err := http.NewRequest(http.MethodGet, originalURL, nil) + if err != nil { + t.Fatal(err) + } + req = WithRequestClass(req, exttransport.RequestClassPlatform) + resp, err := router.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + const rewrittenURL = "https://mirror.example.test/open-apis/test?x=1" + if baseURL != rewrittenURL { + t.Fatalf("base URL = %q, want %q", baseURL, rewrittenURL) + } + if got := req.URL.String(); got != originalURL { + t.Fatalf("caller request URL = %q, want %q", got, originalURL) + } +} + +func TestHTTPPolicyRouterInterceptorObservesRewrittenURL(t *testing.T) { + previousProvider := exttransport.GetProvider() + interceptor := &urlCapturingInterceptor{} + exttransport.Register(rewriteTestProvider{ + testProvider: testProvider{interceptor: interceptor}, + rewriter: rewriteFunc(func(rawURL string) string { + return strings.Replace(rawURL, "source.example.test", "mirror.example.test", 1) + }), + }) + t.Cleanup(func() { exttransport.Register(previousProvider) }) + + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + return noContentResponse(req), nil + }) + transport := WrapWithExtension(base) + req, err := http.NewRequest(http.MethodGet, "https://source.example.test/path", nil) + if err != nil { + t.Fatal(err) + } + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + if interceptor.url != "https://mirror.example.test/path" { + t.Fatalf("interceptor URL = %q, want rewritten URL", interceptor.url) + } +} + +func TestHTTPPolicyRouterClassifiesOriginalURLsAndScopesOnlyInterceptor(t *testing.T) { + previousProvider := exttransport.GetProvider() + interceptor := &testHeaderInterceptor{} + exttransport.Register(scopedRewriteTestProvider{ + rewriteTestProvider: rewriteTestProvider{ + testProvider: testProvider{interceptor: interceptor}, + rewriter: rewriteFunc(func(rawURL string) string { + rawURL = strings.Replace(rawURL, "open.feishu.cn", "open.mirror.test", 1) + return strings.Replace(rawURL, ".example.test", ".mirror.test", 1) + }), + }, + supported: exttransport.RequestClassPlatform, + }) + t.Cleanup(func() { exttransport.Register(previousProvider) }) + + type receivedRequest struct { + url string + header string + } + var platform, external receivedRequest + router := NewHTTPPolicyRouter( + roundTripFunc(func(req *http.Request) (*http.Response, error) { + platform = receivedRequest{url: req.URL.String(), header: req.Header.Get("X-Test-Platform")} + return noContentResponse(req), nil + }), + roundTripFunc(func(req *http.Request) (*http.Response, error) { + external = receivedRequest{url: req.URL.String(), header: req.Header.Get("X-Test-Platform")} + return noContentResponse(req), nil + }), + ) + + for _, rawURL := range []string{ + "https://open.feishu.cn/open-apis/test", + "https://external.example.test/file", + } { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + t.Fatal(err) + } + resp, err := router.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + } + + if platform.url != "https://open.mirror.test/open-apis/test" { + t.Fatalf("platform URL = %q, want rewritten platform URL", platform.url) + } + if platform.header != "routed" { + t.Fatalf("platform interceptor header = %q, want routed", platform.header) + } + if external.url != "https://external.mirror.test/file" { + t.Fatalf("external URL = %q, want rewritten URL", external.url) + } + if external.header != "" { + t.Fatalf("external interceptor header = %q, want empty for scoped interceptor", external.header) + } + if interceptor.calls != 1 { + t.Fatalf("interceptor calls = %d, want platform only", interceptor.calls) + } +} + +func TestHTTPPolicyRouterRejectsInvalidRewriteBeforeBase(t *testing.T) { + previousProvider := exttransport.GetProvider() + exttransport.Register(rewriteTestProvider{ + testProvider: testProvider{}, + rewriter: rewriteFunc(func(string) string { return "/relative" }), + }) + t.Cleanup(func() { exttransport.Register(previousProvider) }) + + baseCalls := 0 + base := roundTripFunc(func(*http.Request) (*http.Response, error) { + baseCalls++ + return nil, nil + }) + router := NewHTTPPolicyRouter(base, base) + req, err := http.NewRequest(http.MethodGet, "https://example.test/path", nil) + if err != nil { + t.Fatal(err) + } + resp, err := router.RoundTrip(req) + if resp != nil { + t.Fatalf("response = %v, want nil", resp) + } + var configErr *errs.ConfigError + if !errors.As(err, &configErr) { + t.Fatalf("RoundTrip() error = %T %v, want *errs.ConfigError", err, err) + } + if baseCalls != 0 { + t.Fatalf("base calls = %d, want 0", baseCalls) + } +} + +func TestHTTPPolicyRouterResolvesURLRewriterOnce(t *testing.T) { + interceptorCalls := 0 + rewriteCalls := 0 + previousProvider := exttransport.GetProvider() + exttransport.Register(rewriteTestProvider{ + testProvider: testProvider{resolveCalls: &interceptorCalls}, + rewriter: rewriteFunc(func(rawURL string) string { return rawURL }), + rewriteCalls: &rewriteCalls, + }) + t.Cleanup(func() { exttransport.Register(previousProvider) }) + + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + return noContentResponse(req), nil + }) + _ = NewHTTPPolicyRouter(base, base) + + if interceptorCalls != 1 { + t.Fatalf("ResolveInterceptor() calls = %d, want 1 per router", interceptorCalls) + } + if rewriteCalls != 1 { + t.Fatalf("ResolveURLRewriter() calls = %d, want 1 per router", rewriteCalls) + } +} + func TestSDKBootstrapBridgeBlocksCrossOriginRedirectAfterSameOriginHop(t *testing.T) { var externalCalls atomic.Int32 var relayBody string diff --git a/internal/urlrewrite/rewrite.go b/internal/urlrewrite/rewrite.go index d1025b2628..dd1077f229 100644 --- a/internal/urlrewrite/rewrite.go +++ b/internal/urlrewrite/rewrite.go @@ -24,7 +24,14 @@ type Resolver struct { // Providers that do not implement URLRewriterProvider, and providers that // return a nil rewriter, produce an identity resolver. func Resolve(ctx context.Context) *Resolver { - p, ok := exttransport.GetProvider().(exttransport.URLRewriterProvider) + return ResolveProvider(ctx, exttransport.GetProvider()) +} + +// ResolveProvider resolves the URL rewriter from p. Callers that have already +// selected a provider should use this function so related extension hooks use +// the same provider instance. +func ResolveProvider(ctx context.Context, provider exttransport.Provider) *Resolver { + p, ok := provider.(exttransport.URLRewriterProvider) if !ok { return &Resolver{} } diff --git a/internal/urlrewrite/rewrite_test.go b/internal/urlrewrite/rewrite_test.go index 56da8b4387..d05cdafb60 100644 --- a/internal/urlrewrite/rewrite_test.go +++ b/internal/urlrewrite/rewrite_test.go @@ -67,6 +67,23 @@ func TestRewriteIdentityWithoutURLRewriter(t *testing.T) { } } +func TestResolveProviderUsesCapturedProvider(t *testing.T) { + captured := testProvider{rewriter: rewriteFunc(func(string) string { + return "https://captured.example.test/path" + })} + withProvider(t, testProvider{rewriter: rewriteFunc(func(string) string { + return "https://registered.example.test/path" + })}) + + got, err := ResolveProvider(context.Background(), captured).Rewrite("https://source.example.test/path") + if err != nil { + t.Fatalf("Rewrite() error = %v", err) + } + if got != "https://captured.example.test/path" { + t.Fatalf("Rewrite() = %q, want URL from captured provider", got) + } +} + func TestRewriteIdentityPreservesRawURL(t *testing.T) { raw := "not a valid URL %2F?x=1+2&x=3" withProvider(t, testProvider{rewriter: rewriteFunc(func(string) string { return raw })}) From 1d6b582d95a5c28c57ee4fd10f2d0d5b25d9ada3 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:39:40 +0800 Subject: [PATCH 03/18] feat: rewrite fixed external command URLs --- internal/selfupdate/updater.go | 24 ++++++++ internal/selfupdate/updater_test.go | 86 +++++++++++++++++++++++++++++ shortcuts/apps/apps_init.go | 17 ++++-- shortcuts/apps/apps_init_test.go | 62 +++++++++++++++++++++ 4 files changed, 185 insertions(+), 4 deletions(-) diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go index 804d34f7d0..b0944d54d0 100644 --- a/internal/selfupdate/updater.go +++ b/internal/selfupdate/updater.go @@ -18,6 +18,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/transport" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/internal/vfs" ) @@ -342,6 +343,10 @@ func (u *Updater) InstallAllSkills(source string) *NpmResult { } func (u *Updater) StageSuite(source, dir string) *NpmResult { + source, result := rewriteSkillsSource(source) + if result != nil { + return result + } suiteSource := strings.TrimSuffix(strings.TrimRight(source, "/"), "/regular") + "/isolated" return u.runSkillsCommandInDir(dir, "-y", "skills", "add", suiteSource, "-s", "lark-suite", "-y") } @@ -358,6 +363,10 @@ func (u *Updater) RemoveGlobalSkills(names []string) *NpmResult { } func (u *Updater) runSkillsAdd(source string) *NpmResult { + source, result := rewriteSkillsSource(source) + if result != nil { + return result + } return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y") } @@ -366,12 +375,27 @@ func (u *Updater) runSkillsListGlobal() *NpmResult { } func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult { + source, result := rewriteSkillsSource(source) + if result != nil { + return result + } args := []string{"-y", "skills", "add", source, "-s"} args = append(args, nameList...) args = append(args, "-g", "-y") return u.runSkillsCommand(args...) } +// rewriteSkillsSource applies the optional URL rewriter to the CLI-owned +// skills source passed to npx or pnpm. A malformed rewritten URL prevents the +// external command from running. +func rewriteSkillsSource(source string) (string, *NpmResult) { + rewritten, err := urlrewrite.Rewrite(context.Background(), source) + if err != nil { + return "", &NpmResult{Err: err} + } + return rewritten, nil +} + // skillsInvocation decides how to launch the `skills` CLI. When the lark-cli // itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so // pnpm-only environments (pnpm's standalone installer bundles Node without diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go index 548715628d..61f2263d21 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -18,6 +18,8 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" + exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/vfs" ) @@ -30,6 +32,29 @@ type executableTestFS struct { func (f executableTestFS) Executable() (string, error) { return f.exe, nil } +type skillsRewriteProvider struct { + rewriter exttransport.URLRewriter +} + +func (skillsRewriteProvider) Name() string { return "skills-rewrite" } + +func (skillsRewriteProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { return nil } + +func (p skillsRewriteProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { + return p.rewriter +} + +type skillsRewriteFunc func(string) string + +func (f skillsRewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } + +func withSkillsRewriteProvider(t *testing.T, rewriter exttransport.URLRewriter) { + t.Helper() + previous := exttransport.GetProvider() + exttransport.Register(skillsRewriteProvider{rewriter: rewriter}) + t.Cleanup(func() { exttransport.Register(previous) }) +} + // lookPathMock patches execLookPath within VerifyBinary for controlled testing. // Do not use t.Parallel() in tests that install this mock — it mutates a package-level var. type lookPathMock struct { @@ -240,6 +265,67 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) { } } +func TestSkillsCommandsRewriteSourcesBeforeInvocation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell script") + } + dir := t.TempDir() + script := filepath.Join(dir, "npx") + logPath := filepath.Join(dir, "npx.log") + if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \""+logPath+"\"\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + withSkillsRewriteProvider(t, skillsRewriteFunc(func(rawURL string) string { + if strings.HasPrefix(rawURL, "https://open.feishu.cn") { + return strings.Replace(rawURL, "https://open.feishu.cn", "http://mirror.example.test", 1) + } + return rawURL + })) + + u := New() + if result := u.StageSuite("https://open.feishu.cn/lark-cli/skills/regular", "."); result.Err != nil { + t.Fatalf("StageSuite() err = %v", result.Err) + } + if result := u.InstallSkills("https://open.feishu.cn/lark-cli/skills/regular", []string{"lark-mail"}); result.Err != nil { + t.Fatalf("InstallSkills() err = %v", result.Err) + } + if result := u.InstallAllSkills("https://open.feishu.cn/lark-cli/skills/regular"); result.Err != nil { + t.Fatalf("InstallAllSkills() err = %v", result.Err) + } + + raw, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + got := strings.Split(strings.TrimSpace(string(raw)), "\n") + want := []string{ + "-y skills add http://mirror.example.test/lark-cli/skills/isolated -s lark-suite -y", + "-y skills add http://mirror.example.test/lark-cli/skills/regular -s lark-mail -g -y", + "-y skills add http://mirror.example.test/lark-cli/skills/regular -g -y", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("commands = %q, want %q", got, want) + } +} + +func TestSkillsCommandsRejectInvalidRewrittenSource(t *testing.T) { + withSkillsRewriteProvider(t, skillsRewriteFunc(func(string) string { return "/relative" })) + called := false + u := &Updater{SkillsCommandOverride: func(args ...string) *NpmResult { + called = true + return &NpmResult{} + }} + + result := u.InstallAllSkills("https://open.feishu.cn/lark-cli/skills/regular") + if result.Err == nil || !errs.IsConfig(result.Err) { + t.Fatalf("InstallAllSkills() error = %v, want config error", result.Err) + } + if called { + t.Fatal("skills command ran after invalid rewritten source") + } +} + func TestStageSuiteUsesProvidedWorkingDirectory(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses a POSIX shell script") diff --git a/shortcuts/apps/apps_init.go b/shortcuts/apps/apps_init.go index 4fd250295f..220a4a974d 100644 --- a/shortcuts/apps/apps_init.go +++ b/shortcuts/apps/apps_init.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/charcheck" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/shortcuts/common" ) @@ -408,12 +409,16 @@ func isEmptyRepo(ctx context.Context, dir string) (bool, error) { // Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch + // conditional `skills sync`. Returns "init" or "upgrade". func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (string, error) { + registry, err := urlrewrite.Rewrite(ctx, npmRegistry) + if err != nil { + return "", err + } empty, err := isEmptyRepo(ctx, dir) if err != nil { return "", err } if empty { - args := scaffoldInitArgs(appType, appID, sourcePath) + args := scaffoldInitArgsWithRegistry(registry, appType, appID, sourcePath) if _, stderr, err := initRunner.Run(ctx, dir, "npx", args...); err != nil { return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err)) } @@ -421,7 +426,7 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s } policy := policyForAppType(appType) if !policy.skipAppSync { - if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil { + if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", registry, miaodaCLIPkg, "app", "sync"); err != nil { return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err)) } } @@ -429,7 +434,7 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s return "", err } if !policy.skipSkillsSync && !hasSteeringSkills(dir) { - if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil { + if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", registry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil { return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err)) } } @@ -446,7 +451,11 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s // translate the app type; mapping the app type to a concrete tech stack is the // downstream tool's responsibility. func scaffoldInitArgs(appType, appID, sourcePath string) []string { - base := []string{"-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "init"} + return scaffoldInitArgsWithRegistry(npmRegistry, appType, appID, sourcePath) +} + +func scaffoldInitArgsWithRegistry(registry, appType, appID, sourcePath string) []string { + base := []string{"-y", "--prefer-online", "--registry", registry, miaodaCLIPkg, "app", "init"} at := appType if at == "" { at = "full_stack" diff --git a/shortcuts/apps/apps_init_test.go b/shortcuts/apps/apps_init_test.go index 04f0dbb331..267455a902 100644 --- a/shortcuts/apps/apps_init_test.go +++ b/shortcuts/apps/apps_init_test.go @@ -18,6 +18,7 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/errs" + exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" @@ -156,6 +157,29 @@ func withFakeRunner(t *testing.T, f *fakeCommandRunner) { t.Cleanup(func() { initRunner = orig }) } +type appsRewriteProvider struct { + rewriter exttransport.URLRewriter +} + +func (appsRewriteProvider) Name() string { return "apps-rewrite" } + +func (appsRewriteProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { return nil } + +func (p appsRewriteProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { + return p.rewriter +} + +type appsRewriteFunc func(string) string + +func (f appsRewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } + +func withAppsRewriteProvider(t *testing.T, rewriter exttransport.URLRewriter) { + t.Helper() + previous := exttransport.GetProvider() + exttransport.Register(appsRewriteProvider{rewriter: rewriter}) + t.Cleanup(func() { exttransport.Register(previous) }) +} + func stubAppType(reg *httpmock.Registry, appID, appType string) { reg.Register(&httpmock.Stub{ Method: "GET", @@ -273,6 +297,44 @@ func TestRunScaffold_EmptyRepo(t *testing.T) { } } +func TestRunScaffoldRewritesFixedRegistry(t *testing.T) { + dir := t.TempDir() + f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}} + withFakeRunner(t, f) + withAppsRewriteProvider(t, appsRewriteFunc(func(rawURL string) string { + if rawURL == npmRegistry { + return "http://registry.example.test" + } + return rawURL + })) + + if _, err := runScaffold(context.Background(), dir, "app_x", "", ""); err != nil { + t.Fatalf("runScaffold() error = %v", err) + } + for _, call := range f.calls { + if len(call) < 2 || call[1] != "npx" { + continue + } + if !containsAll(call, "--registry", "http://registry.example.test") { + t.Fatalf("npx call = %v, want rewritten registry", call) + } + } +} + +func TestRunScaffoldRejectsInvalidRewrittenRegistry(t *testing.T) { + f := &fakeCommandRunner{} + withFakeRunner(t, f) + withAppsRewriteProvider(t, appsRewriteFunc(func(string) string { return "/relative" })) + + _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "") + if err == nil || !errs.IsConfig(err) { + t.Fatalf("runScaffold() error = %v, want config error", err) + } + if len(f.calls) != 0 { + t.Fatalf("commands = %v, want none after invalid registry", f.calls) + } +} + func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) { dir := t.TempDir() // no steering dir, no meta.json f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}} From fcdc0b715d861abd80263e4446f5fe1c94201cd0 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:51:34 +0800 Subject: [PATCH 04/18] feat: rewrite generated CLI URLs --- cmd/build.go | 17 ++ cmd/event/console_url.go | 18 +- cmd/event/console_url_test.go | 3 +- cmd/event/consume.go | 22 +- cmd/event/preflight_test.go | 22 +- cmd/event/service_adapters.go | 2 +- cmd/root_help.go | 19 +- cmd/root_test.go | 31 +++ cmd/service/service.go | 8 +- cmd/service/service_test.go | 1 + cmd/update/update.go | 114 +++++++--- cmd/update/update_test.go | 61 ++++- internal/errclass/classify.go | 20 +- internal/errclass/classify_test.go | 8 +- internal/registry/scope_hint.go | 10 +- internal/registry/scope_hint_test.go | 15 +- shortcuts/calendar/description_rich_images.go | 11 +- .../calendar/description_rich_images_test.go | 11 +- shortcuts/common/permission_grant.go | 5 +- shortcuts/common/resource_url.go | 28 ++- shortcuts/common/resource_url_test.go | 11 +- shortcuts/common/runner.go | 1 + shortcuts/doc/docs_create_v2.go | 17 +- shortcuts/doc/docs_fetch_im_markdown.go | 26 ++- shortcuts/doc/docs_fetch_im_markdown_test.go | 11 +- shortcuts/doc/docs_fetch_v2.go | 4 +- shortcuts/drive/drive_copy.go | 13 +- shortcuts/drive/drive_create_folder.go | 4 +- shortcuts/drive/drive_import.go | 4 +- shortcuts/drive/drive_inspect.go | 5 +- .../drive/drive_permission_get_setting.go | 15 +- .../drive_permission_get_setting_test.go | 11 +- shortcuts/drive/drive_update_title.go | 14 +- shortcuts/im/chat_app_link.go | 19 +- shortcuts/im/chat_app_link_test.go | 6 +- shortcuts/im/convert_lib/content_convert.go | 37 ++- .../im/convert_lib/content_media_misc_test.go | 11 +- shortcuts/im/im_chat_create.go | 4 +- shortcuts/im/im_chat_list.go | 4 +- shortcuts/im/im_chat_messages_list.go | 6 +- shortcuts/im/im_messages_mget.go | 6 +- shortcuts/im/im_messages_search.go | 5 +- shortcuts/im/im_threads_messages_list.go | 6 +- shortcuts/mail/large_attachment.go | 213 +++++++++++++++++- shortcuts/mail/large_attachment_test.go | 61 +++++ shortcuts/mail/mail_forward.go | 10 +- shortcuts/okr/okr_progress_create.go | 7 +- .../lark_sheets_spreadsheet_management.go | 4 +- shortcuts/slides/slides_create.go | 5 +- shortcuts/vc/helpers.go | 5 +- shortcuts/wiki/wiki_helpers.go | 9 +- shortcuts/wiki/wiki_node_copy.go | 4 +- shortcuts/wiki/wiki_node_create.go | 16 +- shortcuts/wiki/wiki_node_create_test.go | 10 +- 54 files changed, 829 insertions(+), 181 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index 9b30053f05..ff3112be32 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -374,6 +374,10 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, // mechanically unchanged. var hasConcealedCommands bool runtime.surface, hasConcealedCommands = applyDistributionPresentation(rootCmd, cfg.presentation, denied) + if err := applyRewrittenRootUsageTemplate(ctx, rootCmd, runtime.surface); err != nil { + installRootUsageRewriteErrorGuard(rootCmd, err) + return finalizeFailedBuild(runtime, rootCmd) + } // Resolve skill assets and canonical references before installing hooks. // A declared customization is a build-integrity boundary: failure must @@ -419,6 +423,19 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, return runtime, rootCmd, hookRegistry } +func applyRewrittenRootUsageTemplate(ctx context.Context, root *cobra.Command, plan *surface.Plan) error { + template, err := rewrittenRootUsageTemplate(ctx, plan) + if err != nil { + return err + } + root.SetUsageTemplate(template) + return nil +} + +func installRootUsageRewriteErrorGuard(root *cobra.Command, err error) { + installFatalGuard(root, func() error { return err }) +} + func finalizeFailedBuild(runtime *buildRuntime, root *cobra.Command) (*buildRuntime, *cobra.Command, *hook.Registry) { finalizeRootCommandGroups(root, runtime.surface) return runtime, root, nil diff --git a/cmd/event/console_url.go b/cmd/event/console_url.go index efe95597c7..3edc9d7a6f 100644 --- a/cmd/event/console_url.go +++ b/cmd/event/console_url.go @@ -6,12 +6,14 @@ package event import ( "bytes" "compress/gzip" + "context" "encoding/base64" "encoding/json" "fmt" "github.com/larksuite/cli/internal/core" eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/urlrewrite" ) // Landing-page contract for the scan-to-enable deep link, verified against the @@ -67,28 +69,28 @@ func encodeAddons(a ManifestAddons) (string, error) { } // consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks. -func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) { +func consoleAddonsURL(ctx context.Context, brand core.LarkBrand, appID string, a ManifestAddons) (string, error) { encoded, err := encodeAddons(a) if err != nil { return "", err } host := core.ResolveEndpoints(brand).Open - return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil + return urlrewrite.Rewrite(ctx, fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded)) } // consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails. -func consoleLandingURL(brand core.LarkBrand, appID string) string { +func consoleLandingURL(ctx context.Context, brand core.LarkBrand, appID string) (string, error) { host := core.ResolveEndpoints(brand).Open - return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID) + return urlrewrite.Rewrite(ctx, fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)) } // addonsHintURL returns the scan URL, degrading to the bare landing page on encode error. -func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string { - url, err := consoleAddonsURL(brand, appID, a) +func addonsHintURL(ctx context.Context, brand core.LarkBrand, appID string, a ManifestAddons) (string, error) { + url, err := consoleAddonsURL(ctx, brand, appID, a) if err != nil { - return consoleLandingURL(brand, appID) + return consoleLandingURL(ctx, brand, appID) } - return url + return url, nil } // missingScopeAddons routes missing scopes into the identity-appropriate section. diff --git a/cmd/event/console_url_test.go b/cmd/event/console_url_test.go index a9f3ce1eec..ff7014bbb6 100644 --- a/cmd/event/console_url_test.go +++ b/cmd/event/console_url_test.go @@ -6,6 +6,7 @@ package event import ( "bytes" "compress/gzip" + "context" "encoding/base64" "encoding/json" "io" @@ -55,7 +56,7 @@ func TestEncodeAddons_RoundTrip(t *testing.T) { } func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) { - url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}}) + url, err := consoleAddonsURL(context.Background(), core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}}) if err != nil { t.Fatalf("url: %v", err) } diff --git a/cmd/event/consume.go b/cmd/event/consume.go index 538e7065c9..d91ab404b6 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -348,7 +348,11 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) (checked bool, err e WithIdentity(string(pf.identity)). WithMissingScopes(missing...) if pf.identity.IsBot() { - permissionErr.WithHint("%s", botScopeRemediationHint(pf.brand, pf.appID, missing)) + hint, hintErr := botScopeRemediationHint(ctx, pf.brand, pf.appID, missing) + if hintErr != nil { + return true, hintErr + } + permissionErr.WithHint("%s", hint) } // The scope check itself completed, so the precondition is answered even // though it answered "missing". A user-identity hint is deliberately left @@ -361,15 +365,18 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) (checked bool, err e // The bot-specific scan-to-enable link adds the scopes to the app manifest, // after which the tenant token carries them. User recovery is generated from // the PermissionError's identity and missing_scopes by the root presenter. -func botScopeRemediationHint(brand core.LarkBrand, appID string, missing []string) string { - return fmt.Sprintf("grant these scopes by scanning: %s", - addonsHintURL(brand, appID, missingScopeAddons(core.AsBot, missing))) +func botScopeRemediationHint(ctx context.Context, brand core.LarkBrand, appID string, missing []string) (string, error) { + url, err := addonsHintURL(ctx, brand, appID, missingScopeAddons(core.AsBot, missing)) + if err != nil { + return "", err + } + return fmt.Sprintf("grant these scopes by scanning: %s", url), nil } // preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed // in the app's console 底账 — published app_versions for event subscriptions, // application/get subscribed_callbacks for callback subscriptions. -func preflightEventTypes(pf *preflightCtx) error { +func preflightEventTypes(ctx context.Context, pf *preflightCtx) error { if len(pf.keyDef.RequiredConsoleEvents) == 0 { return nil } @@ -403,7 +410,10 @@ func preflightEventTypes(pf *preflightCtx) error { return nil } - url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing)) + url, err := addonsHintURL(ctx, pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing)) + if err != nil { + return err + } return errs.NewValidationError(errs.SubtypeFailedPrecondition, "EventKey %s requires %s not subscribed in console: %s", pf.keyDef.Key, noun, strings.Join(missing, ", ")). diff --git a/cmd/event/preflight_test.go b/cmd/event/preflight_test.go index e2509d369f..e1b32d98bc 100644 --- a/cmd/event/preflight_test.go +++ b/cmd/event/preflight_test.go @@ -4,6 +4,7 @@ package event import ( + "context" "errors" "strings" "testing" @@ -35,7 +36,7 @@ func TestPreflightEventTypes_NilAppVer_SkipsCheck(t *testing.T) { EventType: "im.message.receive_v1", RequiredConsoleEvents: []string{"im.message.receive_v1"}, } - if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, nil)); err != nil { + if err := preflightEventTypes(context.Background(), newPreflightCtx("cli_x", "feishu", "", def, nil)); err != nil { t.Fatalf("nil appVer must be a weak-dependency skip, got err: %v", err) } } @@ -46,7 +47,7 @@ func TestPreflightEventTypes_EmptyRequired_SkipsEvenIfEventTypeSet(t *testing.T) EventType: "im.message.message_read_v1", } appVer := &appmeta.AppVersion{EventTypes: []string{"im.message.receive_v1"}} - if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil { + if err := preflightEventTypes(context.Background(), newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil { t.Fatalf("empty RequiredConsoleEvents must skip, got: %v", err) } } @@ -65,7 +66,7 @@ func TestPreflightEventTypes_AllSubscribed_Passes(t *testing.T) { "im.message.reaction.deleted_v1", "im.message.receive_v1", }} - if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil { + if err := preflightEventTypes(context.Background(), newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil { t.Fatalf("unexpected error: %v", err) } } @@ -82,7 +83,7 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) { appVer := &appmeta.AppVersion{EventTypes: []string{ "mail.user_mailbox.event.message_received_v1", }} - err := preflightEventTypes(newPreflightCtx("cli_XXXXXXXXXXXXXXXX", "feishu", "", def, appVer)) + err := preflightEventTypes(context.Background(), newPreflightCtx("cli_XXXXXXXXXXXXXXXX", "feishu", "", def, appVer)) if err == nil { t.Fatal("expected error for missing subscription") } @@ -187,7 +188,7 @@ func TestPreflightEventTypes_CallbackMissing(t *testing.T) { RequiredConsoleEvents: []string{"card.action.trigger"}, }, } - err := preflightEventTypes(pf) + err := preflightEventTypes(context.Background(), pf) if err == nil { t.Fatal("expected error for missing callback") } @@ -216,7 +217,7 @@ func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) { RequiredConsoleEvents: []string{"card.action.trigger"}, }, } - if err := preflightEventTypes(pf); err != nil { + if err := preflightEventTypes(context.Background(), pf); err != nil { t.Errorf("expected skip (nil), got %v", err) } } @@ -237,7 +238,7 @@ func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) { RequiredConsoleEvents: []string{"card.action.trigger"}, }, } - err := preflightEventTypes(pf) + err := preflightEventTypes(context.Background(), pf) if err == nil { t.Fatal("expected error for missing callback when none are subscribed") } @@ -259,13 +260,16 @@ func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) { RequiredConsoleEvents: []string{"card.action.trigger"}, }, } - if err := preflightEventTypes(pf); err != nil { + if err := preflightEventTypes(context.Background(), pf); err != nil { t.Errorf("all callbacks subscribed, unexpected error: %v", err) } } func TestBotScopeRemediationHintUsesScanLink(t *testing.T) { - bot := botScopeRemediationHint(core.BrandFeishu, "cli_x", []string{"im:message"}) + bot, err := botScopeRemediationHint(context.Background(), core.BrandFeishu, "cli_x", []string{"im:message"}) + if err != nil { + t.Fatalf("bot scope remediation hint: %v", err) + } if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") { t.Errorf("bot hint should give the scan link, got: %s", bot) } diff --git a/cmd/event/service_adapters.go b/cmd/event/service_adapters.go index 5209fc95c7..cac1afefe6 100644 --- a/cmd/event/service_adapters.go +++ b/cmd/event/service_adapters.go @@ -59,7 +59,7 @@ func readPreconditions(ctx context.Context, pf *preflightCtx, appVerErr, tokenEr console.Detail = "console ledger unavailable" } default: - if err := preflightEventTypes(pf); err != nil { + if err := preflightEventTypes(ctx, pf); err != nil { console.Status = appconsume.PreconditionBlocked console.Detail = err.Error() console.BlockErr = err diff --git a/cmd/root_help.go b/cmd/root_help.go index a0446f6f9e..963563a783 100644 --- a/cmd/root_help.go +++ b/cmd/root_help.go @@ -4,9 +4,12 @@ package cmd import ( + "context" + "fmt" "strings" "github.com/larksuite/cli/internal/surface" + "github.com/larksuite/cli/internal/urlrewrite" ) // rootHelpFragment is one framework-owned root-help fragment. A fragment with @@ -142,13 +145,27 @@ Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https:// var rootUsageTemplate = renderRootUsageTemplate(nil) func renderRootUsageTemplate(plan *surface.Plan) string { + return renderRootUsageTemplateWithSkillsURL(plan, "https://github.com/larksuite/cli#agent-skills") +} + +func renderRootUsageTemplateWithSkillsURL(plan *surface.Plan, skillsURL string) string { var b strings.Builder b.WriteString(rootUsageTemplatePrefix) b.WriteString(renderRootHelpFragments(rootUsageSynopsis, plan)) b.WriteString(rootUsageTemplateSuffix) if plan.CanReference(surface.CommandSkillsRead) { - b.WriteString(skillsSetupFooter) + b.WriteString(fmt.Sprintf(`{{if not .HasParent}} + +Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — %s{{end}}`, skillsURL)) } b.WriteByte('\n') return b.String() } + +func rewrittenRootUsageTemplate(ctx context.Context, plan *surface.Plan) (string, error) { + skillsURL, err := urlrewrite.Rewrite(ctx, "https://github.com/larksuite/cli#agent-skills") + if err != nil { + return "", err + } + return renderRootUsageTemplateWithSkillsURL(plan, skillsURL), nil +} diff --git a/cmd/root_test.go b/cmd/root_test.go index d952fed082..1f8394b438 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -5,6 +5,7 @@ package cmd import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -19,6 +20,7 @@ import ( cmdconfig "github.com/larksuite/cli/cmd/config" "github.com/larksuite/cli/cmd/schema" "github.com/larksuite/cli/errs" + exttransport "github.com/larksuite/cli/extension/transport" internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" @@ -90,6 +92,35 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) { } } +type rootURLRewriteProvider struct{} + +func (rootURLRewriteProvider) Name() string { return "test-url-rewrite" } + +func (rootURLRewriteProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { + return nil +} + +func (rootURLRewriteProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { + return rootURLRewriter{} +} + +type rootURLRewriter struct{} + +func (rootURLRewriter) RewriteURL(rawURL string) string { + return strings.Replace(rawURL, "github.com", "mirror.example.test", 1) +} + +func TestBuildRewritesRootSkillsHelpURLAfterProviderRegistration(t *testing.T) { + previous := exttransport.GetProvider() + exttransport.Register(rootURLRewriteProvider{}) + t.Cleanup(func() { exttransport.Register(previous) }) + + _, root, _ := buildInternal(context.Background(), buildInvocationForTest(t), WithoutPlugins()) + if got := root.UsageTemplate(); !strings.Contains(got, "https://mirror.example.test/larksuite/cli#agent-skills") { + t.Fatalf("root help URL was not rewritten:\n%s", got) + } +} + func TestConfigureFlagCompletions(t *testing.T) { t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) }) diff --git a/cmd/service/service.go b/cmd/service/service.go index 28d2711973..3e21a2a01f 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -466,7 +466,7 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider if len(method.RequiredScopes) > 0 { // Strict: ALL requiredScopes must be present if missing := auth.MissingScopes(result.Scopes, method.RequiredScopes); len(missing) > 0 { - return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing) + return newPreflightMissingScopeError(ctx, string(config.Brand), config.AppID, string(identity), missing) } return nil } @@ -486,7 +486,7 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider } } recommended := registry.SelectRecommendedScopeFromStrings(method.Scopes, "user") - return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended}) + return newPreflightMissingScopeError(ctx, string(config.Brand), config.AppID, string(identity), []string{recommended}) } // newPreflightMissingScopeError constructs a PermissionError for the local @@ -498,8 +498,8 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider // SubtypeAppScopeNotApplied (bot-perspective dev-action recovery), and this // pre-flight path is user-perspective SubtypeMissingScope whose recovery is // `lark-cli auth login --scope ...`, not a console deep-link. -func newPreflightMissingScopeError(brand, appID, identity string, missing []string) error { - return errclass.NewMissingScopeError(brand, appID, identity, missing) +func newPreflightMissingScopeError(ctx context.Context, brand, appID, identity string, missing []string) error { + return errclass.NewMissingScopeError(ctx, brand, appID, identity, missing) } // unusableParamValue reports whether a provided path/query parameter value diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index fceae019df..a0bf37c60d 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -56,6 +56,7 @@ func driveMethod(httpMethod string, params map[string]interface{}) meta.Method { func TestNewPreflightMissingScopeErrorUsesCanonicalFieldGate(t *testing.T) { err := newPreflightMissingScopeError( + context.Background(), "feishu", "cli_test", "user", diff --git a/cmd/update/update.go b/cmd/update/update.go index 8f5d7c44ed..8c0d851f0a 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -4,6 +4,7 @@ package cmdupdate import ( + "context" "fmt" stdio "io" "runtime" @@ -19,6 +20,7 @@ import ( "github.com/larksuite/cli/internal/selfupdate" "github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/update" + "github.com/larksuite/cli/internal/urlrewrite" ) const ( @@ -114,7 +116,7 @@ Use --check to only check for updates without installing. The skill name "lark-suite" is reserved for CLI-managed suite layout.`, RunE: func(cmd *cobra.Command, args []string) error { - return updateRun(opts) + return updateRunWithContext(cmd.Context(), opts) }, } cmdutil.DisableAuthCheck(cmd) @@ -128,6 +130,10 @@ The skill name "lark-suite" is reserved for CLI-managed suite layout.`, } func updateRun(opts *UpdateOptions) error { + return updateRunWithContext(nil, opts) +} + +func updateRunWithContext(ctx context.Context, opts *UpdateOptions) error { io := opts.Factory.IOStreams if _, err := skillscheck.ParseLayout(opts.SkillsLayout); err != nil { return reportError(opts, io, "validation", @@ -171,7 +177,7 @@ func updateRun(opts *UpdateOptions) error { return err } } - return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check) + return reportAlreadyUpToDate(ctx, opts, io, cur, latest, skillsResult, opts.Check) } // 4. Detect installation method. @@ -179,14 +185,31 @@ func updateRun(opts *UpdateOptions) error { // 5. --check if opts.Check { - return reportCheckResult(opts, io, cur, latest, detect.CanAutoUpdate()) + return reportCheckResult(ctx, opts, io, cur, latest, detect.CanAutoUpdate()) } // 6. Execute update if !detect.CanAutoUpdate() { - return doManualUpdate(opts, io, cur, latest, detect, updater) + return doManualUpdate(ctx, opts, io, cur, latest, detect, updater) } - return doAutoUpdate(opts, io, cur, latest, detect, updater) + return doAutoUpdate(ctx, opts, io, cur, latest, detect, updater) +} + +type presentationURLs struct { + release string + changelog string +} + +func resolvePresentationURLs(ctx context.Context, latest string) (presentationURLs, error) { + release, err := urlrewrite.Rewrite(ctx, releaseURL(latest)) + if err != nil { + return presentationURLs{}, err + } + changelog, err := urlrewrite.Rewrite(ctx, changelogURL()) + if err != nil { + return presentationURLs{}, err + } + return presentationURLs{release: release, changelog: changelog}, nil } // resolveSkillsBrand returns the skills-source brand: resolved config first, @@ -229,22 +252,26 @@ func reportErrorWithFields(opts *UpdateOptions, io *cmdutil.IOStreams, errType s return typedErr } -func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error { +func reportCheckResult(ctx context.Context, opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error { + urls, err := resolvePresentationURLs(ctx, latest) + if err != nil { + return err + } if opts.JSON { out := map[string]interface{}{ "ok": true, "previous_version": cur, "current_version": cur, "latest_version": latest, "action": "update_available", "auto_update": canAutoUpdate, "message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest), - "url": releaseURL(latest), "changelog": changelogURL(), + "url": urls.release, "changelog": urls.changelog, } applySkillsStatus(out, cur) output.PrintJson(io.Out, out) return nil } fmt.Fprintf(io.ErrOut, "Update available: %s %s %s\n", cur, symArrow(), latest) - fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest)) - fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) + fmt.Fprintf(io.ErrOut, " Release: %s\n", urls.release) + fmt.Fprintf(io.ErrOut, " Changelog: %s\n", urls.changelog) if canAutoUpdate { fmt.Fprintf(io.ErrOut, "\nRun `lark-cli update` to install.\n") } else { @@ -253,7 +280,11 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s return nil } -func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error { +func doManualUpdate(ctx context.Context, opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error { + urls, err := resolvePresentationURLs(ctx, latest) + if err != nil { + return err + } skillsResult := runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout) reason := detect.ManualReason() if opts.JSON { @@ -261,7 +292,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri "ok": true, "previous_version": cur, "latest_version": latest, "action": "manual_required", "message": fmt.Sprintf("Automatic update unavailable: %s (path: %s)", reason, detect.ResolvedPath), - "url": releaseURL(latest), "changelog": changelogURL(), + "url": urls.release, "changelog": urls.changelog, } applySkillsResult(out, skillsResult) if err := reportSkillsFailureWithFields(opts, io, skillsResult, out); err != nil { @@ -272,8 +303,8 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri } fmt.Fprintf(io.ErrOut, "Automatic update unavailable: %s (path: %s).\n\n", reason, detect.ResolvedPath) fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n") - fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest)) - fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) + fmt.Fprintf(io.ErrOut, " Release: %s\n", urls.release) + fmt.Fprintf(io.ErrOut, " Changelog: %s\n", urls.changelog) if detect.Method == selfupdate.InstallPnpm { fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest) } else { @@ -286,7 +317,11 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri return nil } -func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error { +func doAutoUpdate(ctx context.Context, opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error { + urls, err := resolvePresentationURLs(ctx, latest) + if err != nil { + return err + } pm := "npm" install := updater.RunNpmInstall if detect.Method == selfupdate.InstallPnpm { @@ -308,12 +343,16 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string if npmResult.Err != nil { restore() combined := npmResult.CombinedOutput() + hint, hintErr := permissionHint(ctx, combined, pm) + if hintErr != nil { + return hintErr + } if opts.JSON { output.PrintJson(io.Out, map[string]interface{}{ "ok": false, "error": map[string]interface{}{ "type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err), "detail": selfupdate.Truncate(combined, maxNpmOutput), - "hint": permissionHint(combined, pm), + "hint": hint, }, }) return output.ErrBare(output.ExitAPI) @@ -325,7 +364,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string fmt.Fprint(io.ErrOut, npmResult.Stderr.String()) } fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err) - if hint := permissionHint(combined, pm); hint != "" { + if hint != "" { fmt.Fprintf(io.ErrOut, " %s\n", hint) } return output.ErrBare(output.ExitAPI) @@ -336,7 +375,10 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string if err := updater.VerifyBinary(latest); err != nil { restore() msg := fmt.Sprintf("new binary verification failed: %s", err) - hint := verificationFailureHint(updater, latest, pm) + hint, hintErr := verificationFailureHint(ctx, updater, latest, pm) + if hintErr != nil { + return hintErr + } if opts.JSON { output.PrintJson(io.Out, map[string]interface{}{ "ok": false, @@ -355,12 +397,12 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string "previous_version": cur, "current_version": latest, "latest_version": latest, "action": "updated", "message": fmt.Sprintf("lark-cli updated from %s to %s, but skills update failed", cur, latest), - "url": releaseURL(latest), "changelog": changelogURL(), + "url": urls.release, "changelog": urls.changelog, } applySkillsResult(fields, skillsResult) if !opts.JSON { fmt.Fprintf(io.ErrOut, "\n%s lark-cli binary updated from %s to %s\n", symOK(), cur, latest) - fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) + fmt.Fprintf(io.ErrOut, " Changelog: %s\n", urls.changelog) } return reportSkillsFailureWithFields(opts, io, skillsResult, fields) } @@ -370,7 +412,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string "ok": true, "previous_version": cur, "current_version": latest, "latest_version": latest, "action": "updated", "message": fmt.Sprintf("lark-cli updated from %s to %s", cur, latest), - "url": releaseURL(latest), "changelog": changelogURL(), + "url": urls.release, "changelog": urls.changelog, } applySkillsResult(result, skillsResult) output.PrintJson(io.Out, result) @@ -378,7 +420,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string } fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest) - fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) + fmt.Fprintf(io.ErrOut, " Changelog: %s\n", urls.changelog) if skillsResult != nil { skillsPM := "npx" if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable { @@ -390,24 +432,36 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string return nil } -func permissionHint(pmOutput, pm string) string { +func permissionHint(ctx context.Context, pmOutput, pm string) (string, error) { if !strings.Contains(pmOutput, "EACCES") || isWindows() { - return "" + return "", nil } if pm == "pnpm" { - return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli" + url, err := urlrewrite.Rewrite(ctx, "https://pnpm.io/pnpm-cli") + if err != nil { + return "", err + } + return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see " + url, nil + } + url, err := urlrewrite.Rewrite(ctx, "https://docs.npmjs.com/resolving-eacces-permissions-errors") + if err != nil { + return "", err } - return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors" + return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: " + url, nil } -func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string { +func verificationFailureHint(ctx context.Context, updater *selfupdate.Updater, latest, pm string) (string, error) { if updater.CanRestorePreviousVersion() { - return "the previous version has been restored" + return "the previous version has been restored", nil + } + release, err := urlrewrite.Rewrite(ctx, releaseURL(latest)) + if err != nil { + return "", err } if pm == "pnpm" { - return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest)) + return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, release), nil } - return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest)) + return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, release), nil } func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout string) *skillscheck.SyncResult { @@ -450,7 +504,7 @@ func reportSkillsFailureWithFields(opts *UpdateOptions, io *cmdutil.IOStreams, r // fields derived from skillsResult. When check is true, this is the pure // report path (spec §3.6): no side-effects, JSON envelope uses // skills_status (spec §4.2) instead of skills_action. -func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error { +func reportAlreadyUpToDate(ctx context.Context, opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error { if opts.JSON { out := map[string]interface{}{ "ok": true, "previous_version": cur, "current_version": cur, diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index 68df818984..e3bf14cf7a 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -17,6 +17,7 @@ import ( "time" "github.com/larksuite/cli/errs" + exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -26,6 +27,24 @@ import ( const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS" +type updateURLRewriteProvider struct{} + +func (updateURLRewriteProvider) Name() string { return "test-url-rewrite" } + +func (updateURLRewriteProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { + return nil +} + +func (updateURLRewriteProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { + return updateURLRewriter{} +} + +type updateURLRewriter struct{} + +func (updateURLRewriter) RewriteURL(rawURL string) string { + return strings.Replace(rawURL, "github.com", "mirror.example.test", 1) +} + // newTestFactory creates a test factory with minimal config. func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { t.Helper() @@ -907,13 +926,41 @@ func TestReleaseURL(t *testing.T) { } } +func TestUpdateCheckRewritesPresentationURLs(t *testing.T) { + previous := exttransport.GetProvider() + exttransport.Register(updateURLRewriteProvider{}) + t.Cleanup(func() { exttransport.Register(previous) }) + + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json", "--check"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + t.Cleanup(func() { fetchLatest = origFetch }) + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + t.Cleanup(func() { currentVersion = origVersion }) + mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("update --check: %v", err) + } + if got := stdout.String(); !strings.Contains(got, "https://mirror.example.test/larksuite/cli/releases/tag/v2.0.0") || !strings.Contains(got, "https://mirror.example.test/larksuite/cli/blob/main/CHANGELOG.md") { + t.Fatalf("presentation URLs were not rewritten:\n%s", got) + } +} + func TestPermissionHint(t *testing.T) { origOS := currentOS defer func() { currentOS = origOS }() // Linux + npm: EACCES should produce a hint with npm prefix guidance. currentOS = "linux" - hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm") + hint, err := permissionHint(context.Background(), "EACCES: permission denied, access '/usr/local/lib'", "npm") + if err != nil { + t.Fatalf("permissionHint() error = %v", err) + } if !strings.Contains(hint, "npm global prefix") { t.Errorf("expected npm prefix hint on linux, got: %s", hint) } @@ -922,7 +969,10 @@ func TestPermissionHint(t *testing.T) { } // Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo. - pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm") + pnpmHint, err := permissionHint(context.Background(), "EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm") + if err != nil { + t.Fatalf("permissionHint() error = %v", err) + } if !strings.Contains(pnpmHint, "pnpm setup") { t.Errorf("expected pnpm setup hint, got: %s", pnpmHint) } @@ -932,14 +982,17 @@ func TestPermissionHint(t *testing.T) { // Windows: EACCES hint is suppressed (no EACCES on Windows). currentOS = "windows" - hint = permissionHint("EACCES: permission denied", "npm") + hint, err = permissionHint(context.Background(), "EACCES: permission denied", "npm") + if err != nil { + t.Fatalf("permissionHint() error = %v", err) + } if hint != "" { t.Errorf("expected empty hint on Windows, got: %s", hint) } // Non-EACCES error: always empty. currentOS = "linux" - if got := permissionHint("some other error", "npm"); got != "" { + if got, err := permissionHint(context.Background(), "some other error", "npm"); err != nil || got != "" { t.Errorf("expected empty hint for non-EACCES, got: %s", got) } } diff --git a/internal/errclass/classify.go b/internal/errclass/classify.go index 4418d756e8..1d5d2bc7cd 100644 --- a/internal/errclass/classify.go +++ b/internal/errclass/classify.go @@ -4,6 +4,7 @@ package errclass import ( + "context" "encoding/json" "fmt" "net/url" @@ -12,6 +13,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/recovery" + "github.com/larksuite/cli/internal/urlrewrite" ) // ClassifyContext is the contextual data BuildAPIError uses to populate @@ -20,6 +22,7 @@ import ( // Brand through core.ParseBrand, so callers can pass a raw brand string without // coupling this contract to core's brand enum. type ClassifyContext struct { + Context context.Context Brand string // "feishu" | "lark" — drives console_url host AppID string // placed in console_url Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary @@ -300,14 +303,14 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex // API classifier from locally verified scope facts. Generated service // preflight checks use this entrypoint so subtype-specific wire fields and // recovery cannot drift from BuildAPIError. -func NewMissingScopeError(brand, appID, identity string, missing []string) error { +func NewMissingScopeError(ctx context.Context, brand, appID, identity string, missing []string) error { return buildPermissionErrorFromFacts( errs.Problem{ Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope, }, missing, - ClassifyContext{Brand: brand, AppID: appID, Identity: identity}, + ClassifyContext{Context: ctx, Brand: brand, AppID: appID, Identity: identity}, ) } @@ -317,7 +320,10 @@ func buildPermissionErrorFromFacts(p errs.Problem, missing []string, cc Classify if identity == "" { identity = "user" } - consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing) + consoleURL, err := ConsoleURL(cc.Context, cc.Brand, cc.AppID, missing) + if err != nil { + return err + } p.Message = canonicalPermissionMessageForIdentity(p.Subtype, identity, cc.AppID, missing, p.Message) // Permission categories have authoritative recovery guidance (scopes to // grant, console URL), so the curated PermissionHint deliberately overrides @@ -558,9 +564,9 @@ func extractMissingScopes(resp map[string]any) []string { // commas in the `scopes` query parameter so the console can pre-select them. // // brand is "feishu" or "lark"; unknown values default to feishu. -func ConsoleURL(brand, appID string, scopes []string) string { +func ConsoleURL(ctx context.Context, brand, appID string, scopes []string) (string, error) { if appID == "" { - return "" + return "", nil } // QueryEscape both values — clientID and scopes both sit in the query // string, and untrusted content must not be able to inject extra query @@ -569,9 +575,9 @@ func ConsoleURL(brand, appID string, scopes []string) string { base := fmt.Sprintf("%s/page/scope-apply?clientID=%s", core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID)) if len(scopes) == 0 { - return base + return urlrewrite.Rewrite(ctx, base) } - return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ",")) + return urlrewrite.Rewrite(ctx, base+"&scopes="+url.QueryEscape(strings.Join(scopes, ","))) } func intFromAny(v any) int { diff --git a/internal/errclass/classify_test.go b/internal/errclass/classify_test.go index 496c54d8f6..0129b9e004 100644 --- a/internal/errclass/classify_test.go +++ b/internal/errclass/classify_test.go @@ -5,6 +5,7 @@ package errclass_test import ( "bytes" + "context" "encoding/json" "errors" "strings" @@ -525,7 +526,10 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := errclass.ConsoleURL("feishu", tt.appID, tt.scopes) + got, err := errclass.ConsoleURL(context.Background(), "feishu", tt.appID, tt.scopes) + if err != nil { + t.Fatalf("ConsoleURL() error = %v", err) + } for _, want := range tt.wantInURL { if !strings.Contains(got, want) { t.Errorf("ConsoleURL missing escaped substring\n want: %s\n got: %s", want, got) @@ -615,7 +619,7 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) { // Path B: the production constructor used by cmd/service's local // preflight. ConsoleURL is intentionally NOT set on either path for // SubtypeMissingScope — see the gating rationale in buildPermissionError. - directErr := errclass.NewMissingScopeError(brand, appID, identity, missing) + directErr := errclass.NewMissingScopeError(context.Background(), brand, appID, identity, missing) var bufA, bufB bytes.Buffer if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok { diff --git a/internal/registry/scope_hint.go b/internal/registry/scope_hint.go index c1af42d9ff..cc7670e71e 100644 --- a/internal/registry/scope_hint.go +++ b/internal/registry/scope_hint.go @@ -4,10 +4,12 @@ package registry import ( + "context" "fmt" "net/url" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" ) // ExtractRequiredScopes pulls scope names out of the API error's @@ -55,14 +57,14 @@ func SelectRecommendedScopeFromStrings(scopes []string, _ string) string { // BuildConsoleScopeURL returns the developer-console "apply scope" URL for the // given app and scope, branded for feishu / lark. Returns "" when appID or // scope is empty so callers can omit the field cleanly. -func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string { +func BuildConsoleScopeURL(ctx context.Context, brand core.LarkBrand, appID, scope string) (string, error) { if appID == "" || scope == "" { - return "" + return "", nil } - return fmt.Sprintf( + return urlrewrite.Rewrite(ctx, fmt.Sprintf( "%s/page/scope-apply?clientID=%s&scopes=%s", core.ResolveOpenBaseURL(brand), url.QueryEscape(appID), url.QueryEscape(scope), - ) + )) } diff --git a/internal/registry/scope_hint_test.go b/internal/registry/scope_hint_test.go index e19628d6f0..51d1c38625 100644 --- a/internal/registry/scope_hint_test.go +++ b/internal/registry/scope_hint_test.go @@ -4,6 +4,7 @@ package registry import ( + "context" "strings" "testing" @@ -45,7 +46,10 @@ func TestExtractRequiredScopes_NilOrMalformed(t *testing.T) { } func TestBuildConsoleScopeURL_BrandSpecificHost(t *testing.T) { - got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", "docs:permission.member:create") + got, err := BuildConsoleScopeURL(context.Background(), core.BrandFeishu, "cli_xxx", "docs:permission.member:create") + if err != nil { + t.Fatalf("BuildConsoleScopeURL() error = %v", err) + } if !strings.Contains(got, "open.feishu.cn") { t.Errorf("feishu brand should use open.feishu.cn host, got %s", got) } @@ -56,17 +60,20 @@ func TestBuildConsoleScopeURL_BrandSpecificHost(t *testing.T) { t.Errorf("scope not URL-escaped: %s", got) } - got = BuildConsoleScopeURL(core.BrandLark, "cli_yyy", "drive:drive") + got, err = BuildConsoleScopeURL(context.Background(), core.BrandLark, "cli_yyy", "drive:drive") + if err != nil { + t.Fatalf("BuildConsoleScopeURL() error = %v", err) + } if !strings.Contains(got, "open.larksuite.com") { t.Errorf("lark brand should use open.larksuite.com host, got %s", got) } } func TestBuildConsoleScopeURL_EmptyInput(t *testing.T) { - if got := BuildConsoleScopeURL(core.BrandFeishu, "", "docs:doc"); got != "" { + if got, err := BuildConsoleScopeURL(context.Background(), core.BrandFeishu, "", "docs:doc"); err != nil || got != "" { t.Errorf("empty appID should yield empty url, got %s", got) } - if got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", ""); got != "" { + if got, err := BuildConsoleScopeURL(context.Background(), core.BrandFeishu, "cli_xxx", ""); err != nil || got != "" { t.Errorf("empty scope should yield empty url, got %s", got) } } diff --git a/shortcuts/calendar/description_rich_images.go b/shortcuts/calendar/description_rich_images.go index 719e24ea2d..6abed66f38 100644 --- a/shortcuts/calendar/description_rich_images.go +++ b/shortcuts/calendar/description_rich_images.go @@ -4,6 +4,7 @@ package calendar import ( + "context" "fmt" "image" @@ -19,6 +20,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -108,7 +110,10 @@ func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt stri } width, height := decodeImageDimensions(runtime, localPath) - uploadedURL := buildCalendarImagePreviewURL(runtime.Config.Brand, fileToken, width, height, info.Size()) + uploadedURL, err := buildCalendarImagePreviewURL(runtime.Ctx(), runtime.Config.Brand, fileToken, width, height, info.Size()) + if err != nil { + return "", err + } cache[localPath] = uploadedURL return uploadedURL, nil } @@ -156,7 +161,7 @@ func localImagePath(src string) string { return s } -func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string { +func buildCalendarImagePreviewURL(ctx context.Context, brand core.LarkBrand, fileToken string, width, height int, size int64) (string, error) { host := "internal-api-drive-stream.feishu.cn" if brand == core.BrandLark { host = "internal-api-drive-stream.larksuite.com" @@ -168,5 +173,5 @@ func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, if size > 0 { u += fmt.Sprintf("&im_size=%d", size) } - return u + return urlrewrite.Rewrite(ctx, u) } diff --git a/shortcuts/calendar/description_rich_images_test.go b/shortcuts/calendar/description_rich_images_test.go index e41c24b4be..7ed60d098a 100644 --- a/shortcuts/calendar/description_rich_images_test.go +++ b/shortcuts/calendar/description_rich_images_test.go @@ -5,6 +5,7 @@ package calendar import ( "bytes" + "context" "encoding/json" "errors" "image" @@ -71,7 +72,10 @@ func TestBuildCalendarImagePreviewURL(t *testing.T) { {core.BrandFeishu, "feishu.cn"}, {core.BrandLark, "larksuite"}, } { - raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568) + raw, err := buildCalendarImagePreviewURL(context.Background(), tc.brand, "boxcnTOKEN123", 416, 306, 142568) + if err != nil { + t.Fatalf("buildCalendarImagePreviewURL() error = %v", err) + } u, err := url.Parse(raw) if err != nil { t.Fatalf("built URL not parseable: %v", err) @@ -90,7 +94,10 @@ func TestBuildCalendarImagePreviewURL(t *testing.T) { } // With unknown dimensions the helper params are omitted entirely. - raw := buildCalendarImagePreviewURL(core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0) + raw, err := buildCalendarImagePreviewURL(context.Background(), core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0) + if err != nil { + t.Fatalf("buildCalendarImagePreviewURL() error = %v", err) + } if strings.Contains(raw, "im_w") || strings.Contains(raw, "im_size") { t.Errorf("expected no dimension params for unknown size, got %q", raw) } diff --git a/shortcuts/common/permission_grant.go b/shortcuts/common/permission_grant.go index 4cebe39860..23f7c6cb65 100644 --- a/shortcuts/common/permission_grant.go +++ b/shortcuts/common/permission_grant.go @@ -212,7 +212,10 @@ func annotateGrantPermissionError(runtime *RuntimeContext, result map[string]int if runtime.Config == nil || runtime.Config.AppID == "" { return } - consoleURL := registry.BuildConsoleScopeURL(runtime.Config.Brand, runtime.Config.AppID, recommended) + consoleURL, rewriteErr := registry.BuildConsoleScopeURL(runtime.Ctx(), runtime.Config.Brand, runtime.Config.AppID, recommended) + if rewriteErr != nil { + return + } if consoleURL == "" { return } diff --git a/shortcuts/common/resource_url.go b/shortcuts/common/resource_url.go index 29ec31c10e..e2a39f545f 100644 --- a/shortcuts/common/resource_url.go +++ b/shortcuts/common/resource_url.go @@ -4,10 +4,12 @@ package common import ( + "context" "net/url" "strings" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" ) // BuildResourceURL returns a brand-standard, user-facing URL for a freshly @@ -22,10 +24,10 @@ import ( // Returns "" when token is empty or kind is unrecognized — callers should // only set the field when the result is non-empty so that "" never overrides // a real URL the backend already returned. -func BuildResourceURL(brand core.LarkBrand, kind, token string) string { +func BuildResourceURL(ctx context.Context, brand core.LarkBrand, kind, token string) (string, error) { token = strings.TrimSpace(token) if token == "" { - return "" + return "", nil } host := "https://www.feishu.cn" @@ -33,28 +35,30 @@ func BuildResourceURL(brand core.LarkBrand, kind, token string) string { host = "https://www.larksuite.com" } + var resourceURL string switch strings.ToLower(strings.TrimSpace(kind)) { case "docx": - return host + "/docx/" + token + resourceURL = host + "/docx/" + token case "doc": - return host + "/doc/" + token + resourceURL = host + "/doc/" + token case "sheet": - return host + "/sheets/" + token + resourceURL = host + "/sheets/" + token case "bitable": - return host + "/base/" + token + resourceURL = host + "/base/" + token case "wiki": - return host + "/wiki/" + token + resourceURL = host + "/wiki/" + token case "file": - return host + "/file/" + token + resourceURL = host + "/file/" + token case "folder": - return host + "/drive/folder/" + token + resourceURL = host + "/drive/folder/" + token case "mindnote": - return host + "/mindnote/" + token + resourceURL = host + "/mindnote/" + token case "slides": - return host + "/slides/" + token + resourceURL = host + "/slides/" + token default: - return "" + return "", nil } + return urlrewrite.Rewrite(ctx, resourceURL) } // ResourceRef holds the parsed type and token from a Lark resource URL. diff --git a/shortcuts/common/resource_url_test.go b/shortcuts/common/resource_url_test.go index c0109fe9c4..9f6f4c28bf 100644 --- a/shortcuts/common/resource_url_test.go +++ b/shortcuts/common/resource_url_test.go @@ -4,6 +4,7 @@ package common import ( + "context" "testing" "github.com/larksuite/cli/internal/core" @@ -90,7 +91,10 @@ func TestParseResourceURL_RoundTrip(t *testing.T) { for _, kind := range types { t.Run(kind, func(t *testing.T) { - built := BuildResourceURL(core.BrandFeishu, kind, token) + built, err := BuildResourceURL(context.Background(), core.BrandFeishu, kind, token) + if err != nil { + t.Fatalf("BuildResourceURL() error = %v", err) + } if built == "" { t.Fatalf("BuildResourceURL returned empty for kind %q", kind) } @@ -140,7 +144,10 @@ func TestBuildResourceURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := BuildResourceURL(tt.brand, tt.kind, tt.token) + got, err := BuildResourceURL(context.Background(), tt.brand, tt.kind, tt.token) + if err != nil { + t.Fatalf("BuildResourceURL() error = %v", err) + } if got != tt.want { t.Errorf("BuildResourceURL(%q, %q, %q) = %q, want %q", tt.brand, tt.kind, tt.token, got, tt.want) } diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 1c6f5ce5b1..bbfd2a05a1 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -444,6 +444,7 @@ func (ctx *RuntimeContext) APIClassifyContext() errclass.ClassifyContext { larkCmd = strings.TrimPrefix(ctx.Cmd.CommandPath(), "lark ") } return errclass.ClassifyContext{ + Context: ctx.Ctx(), Brand: string(ctx.Config.Brand), AppID: ctx.Config.AppID, Identity: string(ctx.As()), diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index 6c476edd1c..2bcfc6bd6e 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -108,7 +108,9 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error { } augmentDocsCreatePermission(runtime, data) - fallbackDocsCreateURLV2(runtime, data) + if err := fallbackDocsCreateURLV2(runtime, data); err != nil { + return err + } if len(resources) > 0 { doc, _ := data["document"].(map[string]interface{}) if err := finalizeLocalDocResources(runtime, strings.TrimSpace(common.GetString(doc, "document_id")), data, resources); err != nil { @@ -176,19 +178,22 @@ func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string // fallbackDocsCreateURLV2 fills data.document.url with a brand-standard URL // when the OpenAPI response did not include one. Backfills only when missing, // so any tenant-specific URL the backend returned is preserved. -func fallbackDocsCreateURLV2(runtime *common.RuntimeContext, data map[string]interface{}) { +func fallbackDocsCreateURLV2(runtime *common.RuntimeContext, data map[string]interface{}) error { doc, _ := data["document"].(map[string]interface{}) if doc == nil { - return + return nil } if strings.TrimSpace(common.GetString(doc, "url")) != "" { - return + return nil } docID := strings.TrimSpace(common.GetString(doc, "document_id")) if docID == "" { - return + return nil } - if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" { + if u, err := common.BuildResourceURL(runtime.Ctx(), runtime.Config.Brand, "docx", docID); err != nil { + return err + } else if u != "" { doc["url"] = u } + return nil } diff --git a/shortcuts/doc/docs_fetch_im_markdown.go b/shortcuts/doc/docs_fetch_im_markdown.go index 7c94127027..8009244f9f 100644 --- a/shortcuts/doc/docs_fetch_im_markdown.go +++ b/shortcuts/doc/docs_fetch_im_markdown.go @@ -4,12 +4,15 @@ package doc import ( + "context" "fmt" "html" "net/url" "regexp" "strings" "unicode/utf8" + + "github.com/larksuite/cli/internal/urlrewrite" ) type imMarkdownContext struct { @@ -101,25 +104,36 @@ func isIMMarkdownFetch(runtime interface{ Str(string) string }) bool { return strings.TrimSpace(runtime.Str("doc-format")) == "im-markdown" } -func applyFetchIMMarkdown(data map[string]interface{}, docInput string) { +func applyFetchIMMarkdown(ctx context.Context, data map[string]interface{}, docInput string) error { doc, ok := data["document"].(map[string]interface{}) if !ok { - return + return nil } content, ok := doc["content"].(string) if !ok { - return + return nil + } + imCtx, err := newIMMarkdownContext(ctx, docInput) + if err != nil { + return err } - doc["content"] = convertToIMMarkdown(content, newIMMarkdownContext(docInput)) + doc["content"] = convertToIMMarkdown(content, imCtx) + return nil } -func newIMMarkdownContext(docInput string) imMarkdownContext { +func newIMMarkdownContext(ctx context.Context, docInput string) (imMarkdownContext, error) { base := "https://larkoffice.com" raw := strings.TrimSpace(docInput) if extracted, ok := imMarkdownBaseURLFromInput(raw); ok { base = extracted + } else { + var err error + base, err = urlrewrite.Rewrite(ctx, base) + if err != nil { + return imMarkdownContext{}, err + } } - return imMarkdownContext{baseURL: base} + return imMarkdownContext{baseURL: base}, nil } func (c imMarkdownContext) withBlockquote() imMarkdownContext { diff --git a/shortcuts/doc/docs_fetch_im_markdown_test.go b/shortcuts/doc/docs_fetch_im_markdown_test.go index 262c48ed34..cf19f09e6c 100644 --- a/shortcuts/doc/docs_fetch_im_markdown_test.go +++ b/shortcuts/doc/docs_fetch_im_markdown_test.go @@ -4,6 +4,7 @@ package doc import ( + "context" "reflect" "strings" "testing" @@ -62,7 +63,9 @@ func TestApplyFetchIMMarkdown(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - applyFetchIMMarkdown(tt.data, tt.docInput) + if err := applyFetchIMMarkdown(context.Background(), tt.data, tt.docInput); err != nil { + t.Fatalf("applyFetchIMMarkdown() error = %v", err) + } if !reflect.DeepEqual(tt.data, tt.want) { t.Fatalf("data = %#v, want %#v", tt.data, tt.want) } @@ -1076,7 +1079,11 @@ func TestNewIMMarkdownContextExtractsBaseURL(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - if got := newIMMarkdownContext(tt.input).baseURL; got != tt.want { + imCtx, err := newIMMarkdownContext(context.Background(), tt.input) + if err != nil { + t.Fatalf("newIMMarkdownContext() error = %v", err) + } + if got := imCtx.baseURL; got != tt.want { t.Fatalf("baseURL = %q, want %q", got, tt.want) } }) diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index a83200e6a0..6743a4b732 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -81,7 +81,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error { fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning) } if isIMMarkdownFetch(runtime) { - applyFetchIMMarkdown(data, runtime.Str("doc")) + if err := applyFetchIMMarkdown(runtime.Ctx(), data, runtime.Str("doc")); err != nil { + return err + } } runtime.OutFormatRaw(data, nil, func(w io.Writer) { diff --git a/shortcuts/drive/drive_copy.go b/shortcuts/drive/drive_copy.go index c7264c6f5f..e959da69af 100644 --- a/shortcuts/drive/drive_copy.go +++ b/shortcuts/drive/drive_copy.go @@ -123,7 +123,10 @@ var DriveCopy = common.Shortcut{ if copiedToken == "" { return errs.NewInternalError(errs.SubtypeInvalidResponse, "drive copy succeeded but returned no file token (data.file.token)") } - out := buildDriveCopyOutput(runtime, spec, folderToken, data) + out, err := buildDriveCopyOutput(ctx, runtime, spec, folderToken, data) + if err != nil { + return err + } copiedType := common.GetString(data, "file", "type") if copiedType == "" { copiedType = spec.Ref.Type @@ -421,7 +424,7 @@ func buildDriveCopyDryRun(spec driveCopySpec) *common.DryRunAPI { Set("file_token", spec.Ref.Token) } -func buildDriveCopyOutput(runtime *common.RuntimeContext, spec driveCopySpec, folderToken string, data map[string]interface{}) map[string]interface{} { +func buildDriveCopyOutput(ctx context.Context, runtime *common.RuntimeContext, spec driveCopySpec, folderToken string, data map[string]interface{}) (map[string]interface{}, error) { out := map[string]interface{}{ "copied": true, "source_file_token": spec.Ref.Token, @@ -436,7 +439,9 @@ func buildDriveCopyOutput(runtime *common.RuntimeContext, spec driveCopySpec, fo out["file_token"] = token if url := common.GetString(file, "url"); url != "" { out["url"] = url - } else if built := common.BuildResourceURL(runtime.Config.Brand, common.GetString(file, "type"), token); built != "" { + } else if built, err := common.BuildResourceURL(ctx, runtime.Config.Brand, common.GetString(file, "type"), token); err != nil { + return nil, err + } else if built != "" { out["url"] = built } } @@ -446,5 +451,5 @@ func buildDriveCopyOutput(runtime *common.RuntimeContext, spec driveCopySpec, fo if name := common.GetString(file, "name"); name != "" { out["name"] = name } - return out + return out, nil } diff --git a/shortcuts/drive/drive_create_folder.go b/shortcuts/drive/drive_create_folder.go index 4cdeec577c..cb5f8345ba 100644 --- a/shortcuts/drive/drive_create_folder.go +++ b/shortcuts/drive/drive_create_folder.go @@ -94,7 +94,9 @@ var DriveCreateFolder = common.Shortcut{ } if url := strings.TrimSpace(common.GetString(data, "url")); url != "" { out["url"] = url - } else if u := common.BuildResourceURL(runtime.Config.Brand, "folder", folderToken); u != "" { + } else if u, err := common.BuildResourceURL(ctx, runtime.Config.Brand, "folder", folderToken); err != nil { + return err + } else if u != "" { out["url"] = u } if grant := common.AutoGrantCurrentUserDrivePermission(runtime, folderToken, "folder"); grant != nil { diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go index db4ed401f5..69d04375f3 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -194,7 +194,9 @@ func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportPara if statusURL := strings.TrimSpace(status.URL); statusURL != "" { out["url"] = statusURL } else if status.Token != "" { - if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" { + if u, err := common.BuildResourceURL(ctx, runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); err != nil { + return err + } else if u != "" { out["url"] = u } } diff --git a/shortcuts/drive/drive_inspect.go b/shortcuts/drive/drive_inspect.go index de3cd22d83..e39cce53a4 100644 --- a/shortcuts/drive/drive_inspect.go +++ b/shortcuts/drive/drive_inspect.go @@ -141,7 +141,10 @@ var DriveInspect = common.Shortcut{ } // Step 4: Build the resolved URL. - resolvedURL := common.BuildResourceURL(runtime.Config.Brand, docType, docToken) + resolvedURL, err := common.BuildResourceURL(ctx, runtime.Config.Brand, docType, docToken) + if err != nil { + return err + } // Step 5: Build output. result := map[string]interface{}{ diff --git a/shortcuts/drive/drive_permission_get_setting.go b/shortcuts/drive/drive_permission_get_setting.go index 960bdab2ce..ccb035ef4c 100644 --- a/shortcuts/drive/drive_permission_get_setting.go +++ b/shortcuts/drive/drive_permission_get_setting.go @@ -13,6 +13,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -163,11 +164,11 @@ func drivePermissionGetSettingTypeAllowed(docType string) bool { return ok } -func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string { +func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) (string, error) { resourceKind, ok := findDrivePermissionGetSettingResourceKind(s.Type) token := strings.TrimSpace(s.Token) if !ok || token == "" { - return "" + return "", nil } brand := core.LarkBrand("") @@ -178,7 +179,7 @@ func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) strin if brand == core.BrandLark { host = "https://www.larksuite.com" } - return host + resourceKind.CanonicalPath + url.PathEscape(token) + return urlrewrite.Rewrite(runtime.Ctx(), host+resourceKind.CanonicalPath+url.PathEscape(token)) } func validateDrivePermissionGetSettingToken(token string) error { @@ -277,12 +278,16 @@ var DrivePermissionGetSetting = common.Shortcut{ ).WithCause(err) } + displayURL, err := spec.url(runtime) + if err != nil { + return err + } out := map[string]interface{}{"permission_public": permissionPublic} runtime.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintf(w, "Type: %s\n", spec.Type) fmt.Fprintf(w, "Token: %s\n", spec.Token) - if url := spec.url(runtime); url != "" { - fmt.Fprintf(w, "URL: %s\n", url) + if displayURL != "" { + fmt.Fprintf(w, "URL: %s\n", displayURL) } fmt.Fprintf(w, "Permission settings:\n%s\n", permissionPublicPretty) }) diff --git a/shortcuts/drive/drive_permission_get_setting_test.go b/shortcuts/drive/drive_permission_get_setting_test.go index 80fc71a223..62e7c620a1 100644 --- a/shortcuts/drive/drive_permission_get_setting_test.go +++ b/shortcuts/drive/drive_permission_get_setting_test.go @@ -160,7 +160,10 @@ func TestDrivePermissionGetSettingResourceKindsRoundTrip(t *testing.T) { if err != nil { t.Fatalf("read bare-token spec: %v", err) } - resourceURL := bareSpec.url(bareRuntime) + resourceURL, err := bareSpec.url(bareRuntime) + if err != nil { + t.Fatalf("build resource URL: %v", err) + } if resourceURL == "" { t.Fatalf("resource URL is empty for allowed type %q", kind.Type) } @@ -192,7 +195,11 @@ func TestDrivePermissionGetSettingResourceURLUsesConfiguredBrand(t *testing.T) { if err != nil { t.Fatalf("read spec: %v", err) } - if got, want := spec.url(runtime), "https://www.larksuite.com/page/appMetaTok"; got != want { + got, err := spec.url(runtime) + if err != nil { + t.Fatalf("build resource URL: %v", err) + } + if want := "https://www.larksuite.com/page/appMetaTok"; got != want { t.Fatalf("resource URL = %q, want %q", got, want) } } diff --git a/shortcuts/drive/drive_update_title.go b/shortcuts/drive/drive_update_title.go index 68f44db4d7..aa16e5a918 100644 --- a/shortcuts/drive/drive_update_title.go +++ b/shortcuts/drive/drive_update_title.go @@ -143,7 +143,11 @@ var DriveUpdateTitle = common.Shortcut{ return decorateDriveUpdateTitleError(err, spec) } - runtime.Out(buildDriveUpdateTitleOutput(runtime, spec, guard), nil) + out, err := buildDriveUpdateTitleOutput(ctx, runtime, spec, guard) + if err != nil { + return err + } + runtime.Out(out, nil) return nil }, } @@ -486,14 +490,16 @@ func buildDriveUpdateTitleDryRun(spec driveUpdateTitleSpec) *common.DryRunAPI { // buildDriveUpdateTitleOutput reports the applied title: the endpoint answers // with an empty data object, so the submitted state plus what the extension // guard read are the only ground truth available without a follow-up read. -func buildDriveUpdateTitleOutput(runtime *common.RuntimeContext, spec driveUpdateTitleSpec, guard driveUpdateTitleGuard) map[string]interface{} { +func buildDriveUpdateTitleOutput(ctx context.Context, runtime *common.RuntimeContext, spec driveUpdateTitleSpec, guard driveUpdateTitleGuard) (map[string]interface{}, error) { out := map[string]interface{}{ "updated": true, "file_token": spec.Ref.Token, "type": spec.Ref.Type, "title": spec.Title, } - if url := common.BuildResourceURL(runtime.Config.Brand, spec.Ref.Type, spec.Ref.Token); url != "" { + if url, err := common.BuildResourceURL(ctx, runtime.Config.Brand, spec.Ref.Type, spec.Ref.Token); err != nil { + return nil, err + } else if url != "" { out["url"] = url } // previous_title makes a wrong rename reversible in one follow-up command. @@ -503,7 +509,7 @@ func buildDriveUpdateTitleOutput(runtime *common.RuntimeContext, spec driveUpdat if guard.ExtensionAppended != "" { out["extension_appended"] = guard.ExtensionAppended } - return out + return out, nil } // decorateDriveUpdateTitleError adds command-level recovery guidance to the API diff --git a/shortcuts/im/chat_app_link.go b/shortcuts/im/chat_app_link.go index a07522aaee..049e3fcb24 100644 --- a/shortcuts/im/chat_app_link.go +++ b/shortcuts/im/chat_app_link.go @@ -4,40 +4,45 @@ package im import ( + "context" "net/url" "strings" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/shortcuts/common" ) -func addChatAppLinks(chats []map[string]interface{}, runtime *common.RuntimeContext) { +func addChatAppLinks(chats []map[string]interface{}, runtime *common.RuntimeContext) error { if runtime == nil || runtime.Config == nil { - return + return nil } for _, chat := range chats { - if link := assembleChatAppLink(chat["chat_id"], runtime.Config.Brand); link != "" { + if link, err := assembleChatAppLink(runtime.Ctx(), chat["chat_id"], runtime.Config.Brand); err != nil { + return err + } else if link != "" { chat["chat_app_link"] = link } } + return nil } -func assembleChatAppLink(rawChatID interface{}, brand core.LarkBrand) string { +func assembleChatAppLink(ctx context.Context, rawChatID interface{}, brand core.LarkBrand) (string, error) { chatID, _ := rawChatID.(string) chatID = strings.TrimSpace(chatID) if !strings.HasPrefix(chatID, "oc_") { - return "" + return "", nil } domain := resolveChatAppLinkDomain(brand) if domain == "" { - return "" + return "", nil } u := &url.URL{Scheme: "https", Host: domain, Path: "/client/chat/open"} q := url.Values{} q.Set("openChatId", chatID) u.RawQuery = q.Encode() - return u.String() + return urlrewrite.Rewrite(ctx, u.String()) } func resolveChatAppLinkDomain(brand core.LarkBrand) string { diff --git a/shortcuts/im/chat_app_link_test.go b/shortcuts/im/chat_app_link_test.go index 307219ea14..8b92b9f1d5 100644 --- a/shortcuts/im/chat_app_link_test.go +++ b/shortcuts/im/chat_app_link_test.go @@ -47,7 +47,11 @@ func TestAssembleChatAppLink(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := assembleChatAppLink(tt.chatID, tt.brand); got != tt.want { + got, err := assembleChatAppLink(context.Background(), tt.chatID, tt.brand) + if err != nil { + t.Fatalf("assembleChatAppLink() error = %v", err) + } + if got != tt.want { t.Fatalf("assembleChatAppLink() = %q, want %q", got, tt.want) } }) diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go index 1292b7d33c..d25daaa192 100644 --- a/shortcuts/im/convert_lib/content_convert.go +++ b/shortcuts/im/convert_lib/content_convert.go @@ -4,6 +4,7 @@ package convertlib import ( + "context" "encoding/json" "fmt" "math" @@ -13,6 +14,7 @@ import ( "strings" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/shortcuts/common" ) @@ -144,6 +146,12 @@ func FormatMessageItemWithMergePrefetch(m map[string]interface{}, runtime *commo return formatMessageItem(m, runtime, nameCache, mergePrefetch, false) } +// FormatMessageItemWithMergePrefetchE is the error-returning variant for +// command execution paths that synthesize an app link. +func FormatMessageItemWithMergePrefetchE(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) (map[string]interface{}, error) { + return formatMessageItemE(m, runtime, nameCache, mergePrefetch, false) +} + // FormatMessageItemWithMergePrefetchOpts is FormatMessageItemWithMergePrefetch // with an explicit extractResources gate. When extractResources is true and // the message carries downloadable resources, a "resources" block (ref list @@ -154,7 +162,18 @@ func FormatMessageItemWithMergePrefetchOpts(m map[string]interface{}, runtime *c return formatMessageItem(m, runtime, nameCache, mergePrefetch, extractResources) } +// FormatMessageItemWithMergePrefetchOptsE is the error-returning variant for +// command execution paths that synthesize an app link. +func FormatMessageItemWithMergePrefetchOptsE(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, extractResources bool) (map[string]interface{}, error) { + return formatMessageItemE(m, runtime, nameCache, mergePrefetch, extractResources) +} + func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, extractResources bool) map[string]interface{} { + msg, _ := formatMessageItemE(m, runtime, nameCache, mergePrefetch, extractResources) + return msg +} + +func formatMessageItemE(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, extractResources bool) (map[string]interface{}, error) { msgType, _ := m["msg_type"].(string) messageId, _ := m["message_id"].(string) mentions, _ := m["mentions"].([]interface{}) @@ -222,7 +241,11 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, appLink, _ := m["message_app_link"].(string) appLink = strings.TrimSpace(appLink) if appLink == "" && runtime != nil && runtime.Config != nil { - appLink = assembleMessageAppLink(m, runtime.Config.Brand) + var err error + appLink, err = assembleMessageAppLink(runtime.Ctx(), m, runtime.Config.Brand) + if err != nil { + return nil, err + } } if appLink != "" { msg["message_app_link"] = appLink @@ -257,13 +280,13 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, } } - return msg + return msg, nil } -func assembleMessageAppLink(m map[string]interface{}, brand core.LarkBrand) string { +func assembleMessageAppLink(ctx context.Context, m map[string]interface{}, brand core.LarkBrand) (string, error) { domain := resolveAppLinkDomain(brand) if domain == "" { - return "" + return "", nil } chatID, _ := m["chat_id"].(string) @@ -283,7 +306,7 @@ func assembleMessageAppLink(m map[string]interface{}, brand core.LarkBrand) stri q.Set("open_chat_id", chatID) q.Set("thread_position", threadPos) u.RawQuery = q.Encode() - return u.String() + return urlrewrite.Rewrite(ctx, u.String()) } if chatID != "" && okMsgPos { u := &url.URL{Scheme: "https", Host: domain, Path: "/client/chat/open"} @@ -291,9 +314,9 @@ func assembleMessageAppLink(m map[string]interface{}, brand core.LarkBrand) stri q.Set("openChatId", chatID) q.Set("position", msgPos) u.RawQuery = q.Encode() - return u.String() + return urlrewrite.Rewrite(ctx, u.String()) } - return "" + return "", nil } func normalizeMessagePosition(v interface{}) (string, bool) { diff --git a/shortcuts/im/convert_lib/content_media_misc_test.go b/shortcuts/im/convert_lib/content_media_misc_test.go index c207b77c85..8d346dc74a 100644 --- a/shortcuts/im/convert_lib/content_media_misc_test.go +++ b/shortcuts/im/convert_lib/content_media_misc_test.go @@ -4,6 +4,7 @@ package convertlib import ( + "context" "encoding/json" "math" "net/url" @@ -356,7 +357,10 @@ func TestAssembleMessageAppLink_EncodesQueryValues(t *testing.T) { "chat_id": "oc_1+2/3", "message_position": 12, } - gotChat := assembleMessageAppLink(chat, core.BrandFeishu) + gotChat, err := assembleMessageAppLink(context.Background(), chat, core.BrandFeishu) + if err != nil { + t.Fatalf("assembleMessageAppLink() error = %v", err) + } assertURLHasQuery(t, gotChat, "applink.feishu.cn", "/client/chat/open", map[string]string{ "openChatId": "oc_1+2/3", "position": "12", @@ -368,7 +372,10 @@ func TestAssembleMessageAppLink_EncodesQueryValues(t *testing.T) { "thread_id": "omt_1+2/3", "thread_message_position": -1, } - gotThread := assembleMessageAppLink(thread, core.BrandFeishu) + gotThread, err := assembleMessageAppLink(context.Background(), thread, core.BrandFeishu) + if err != nil { + t.Fatalf("assembleMessageAppLink() error = %v", err) + } assertURLHasQuery(t, gotThread, "applink.feishu.cn", "/client/thread/open", map[string]string{ "open_thread_id": "omt_1+2/3", "open_chat_id": "oc_1+2/3", diff --git a/shortcuts/im/im_chat_create.go b/shortcuts/im/im_chat_create.go index 28d6b61351..3d8c0ee92e 100644 --- a/shortcuts/im/im_chat_create.go +++ b/shortcuts/im/im_chat_create.go @@ -126,7 +126,9 @@ var ImChatCreate = common.Shortcut{ "external": resData["external"], } if runtime.Config != nil { - if link := assembleChatAppLink(resData["chat_id"], runtime.Config.Brand); link != "" { + if link, err := assembleChatAppLink(ctx, resData["chat_id"], runtime.Config.Brand); err != nil { + return err + } else if link != "" { outData["chat_app_link"] = link } } diff --git a/shortcuts/im/im_chat_list.go b/shortcuts/im/im_chat_list.go index 4e3536e7df..1e4061fd58 100644 --- a/shortcuts/im/im_chat_list.go +++ b/shortcuts/im/im_chat_list.go @@ -142,7 +142,9 @@ var ImChatList = common.Shortcut{ } items = mfOut.Chats pagination.Items = len(items) - addChatAppLinks(items, runtime) + if err := addChatAppLinks(items, runtime); err != nil { + return err + } // Presentation stage: business data stays backward compatible while the // output layer carries the authoritative pagination outcome for every diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index 8e3114216f..f1024044e4 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -160,7 +160,11 @@ var ImChatMessageList = common.Shortcut{ downloadResources := runtime.Bool("download-resources") messages := make([]map[string]interface{}, 0, len(rawItems)) for _, m := range result.items { - messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources)) + message, err := convertlib.FormatMessageItemWithMergePrefetchOptsE(m, runtime, nameCache, mergePrefetch, downloadResources) + if err != nil { + return err + } + messages = append(messages, message) } // Enrich: resolve sender names for outer messages (reuses cache from merge_forward) diff --git a/shortcuts/im/im_messages_mget.go b/shortcuts/im/im_messages_mget.go index d13b487852..a1a740d4f8 100644 --- a/shortcuts/im/im_messages_mget.go +++ b/shortcuts/im/im_messages_mget.go @@ -83,7 +83,11 @@ var ImMessagesMGet = common.Shortcut{ messages := make([]map[string]interface{}, 0, len(rawItems)) for _, item := range rawItems { m, _ := item.(map[string]interface{}) - messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources)) + message, err := convertlib.FormatMessageItemWithMergePrefetchOptsE(m, runtime, nameCache, mergePrefetch, downloadResources) + if err != nil { + return err + } + messages = append(messages, message) } convertlib.ResolveSenderNames(runtime, messages, nameCache) diff --git a/shortcuts/im/im_messages_search.go b/shortcuts/im/im_messages_search.go index fb2a440a54..ce06fb04fd 100644 --- a/shortcuts/im/im_messages_search.go +++ b/shortcuts/im/im_messages_search.go @@ -178,7 +178,10 @@ var ImMessagesSearch = common.Shortcut{ chatId, _ := m["chat_id"].(string) // Reuse unified content converter - msg := convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch) + msg, err := convertlib.FormatMessageItemWithMergePrefetchE(m, runtime, nameCache, mergePrefetch) + if err != nil { + return err + } if chatId != "" { msg["chat_id"] = chatId } diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 0c24ad2e43..dae5823cb5 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -133,7 +133,11 @@ var ImThreadsMessagesList = common.Shortcut{ downloadResources := runtime.Bool("download-resources") messages := make([]map[string]interface{}, 0, len(rawItems)) for _, m := range result.items { - messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources)) + message, err := convertlib.FormatMessageItemWithMergePrefetchOptsE(m, runtime, nameCache, mergePrefetch, downloadResources) + if err != nil { + return err + } + messages = append(messages, message) } // Enrich: resolve sender names for outer messages (reuses cache from merge_forward) diff --git a/shortcuts/mail/large_attachment.go b/shortcuts/mail/large_attachment.go index 97536fa6a2..91daad78ed 100644 --- a/shortcuts/mail/large_attachment.go +++ b/shortcuts/mail/large_attachment.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" @@ -282,6 +283,43 @@ func buildLargeAttachmentItems(brand core.LarkBrand, lang string, results []larg return items.String() } +// buildRewrittenLargeAttachmentItems applies the optional URL rewrite extension +// to the generated attachment preview and icon URLs before putting them in the +// message body. +func buildRewrittenLargeAttachmentItems(ctx context.Context, brand core.LarkBrand, lang string, results []largeAttachmentResult) (string, error) { + if len(results) == 0 { + return "", nil + } + downloadText := "Download" + if strings.HasPrefix(lang, "zh") { + downloadText = "下载" + } + iconCDN := iconCDNCN + if brand == core.BrandLark { + iconCDN = iconCDNEN + } + var items strings.Builder + for _, att := range results { + iconURL, err := urlrewrite.Rewrite(ctx, iconCDN+fileTypeIcon(att.FileName)) + if err != nil { + return "", err + } + previewURL, err := urlrewrite.Rewrite(ctx, buildLargeAttachmentPreviewURL(brand, att.FileToken)) + if err != nil { + return "", err + } + fmt.Fprintf(&items, largeAttItemTpl, + htmlEscape(iconURL), + htmlEscape(att.FileName), + htmlEscape(common.FormatSize(att.FileSize)), + htmlEscape(previewURL), + htmlEscape(att.FileToken), + downloadText, + ) + } + return items.String(), nil +} + func buildLargeAttachmentHTML(brand core.LarkBrand, lang string, results []largeAttachmentResult) string { if len(results) == 0 { return "" @@ -298,6 +336,26 @@ func buildLargeAttachmentHTML(brand core.LarkBrand, lang string, results []large return fmt.Sprintf(largeAttContainerTpl, timestamp, title, buildLargeAttachmentItems(brand, lang, results)) } +func buildRewrittenLargeAttachmentHTML(ctx context.Context, brand core.LarkBrand, lang string, results []largeAttachmentResult) (string, error) { + if len(results) == 0 { + return "", nil + } + appName := brandDisplayName(brand, lang) + title := "Large file from " + appName + " Mail" + if strings.HasPrefix(lang, "zh") { + title = "来自" + appName + "邮箱的超大附件" + } + timestamp := fmt.Sprintf("%d", time.Now().UnixMilli()) + if len(timestamp) > 9 { + timestamp = timestamp[:9] + } + items, err := buildRewrittenLargeAttachmentItems(ctx, brand, lang, results) + if err != nil { + return "", err + } + return fmt.Sprintf(largeAttContainerTpl, timestamp, title, items), nil +} + func buildLargeAttachmentPlainText(brand core.LarkBrand, lang string, results []largeAttachmentResult) string { if len(results) == 0 { return "" @@ -330,6 +388,42 @@ func buildLargeAttachmentPlainText(brand core.LarkBrand, lang string, results [] return sb.String() } +func buildRewrittenLargeAttachmentPlainText(ctx context.Context, brand core.LarkBrand, lang string, results []largeAttachmentResult) (string, error) { + if len(results) == 0 { + return "", nil + } + + appName := brandDisplayName(brand, lang) + title := "Large file from " + appName + " Mail" + downloadText := "Download" + if strings.HasPrefix(lang, "zh") { + title = "来自" + appName + "邮箱的超大附件" + downloadText = "下载" + } + + var sb strings.Builder + sb.WriteString("\n") + sb.WriteString(title) + sb.WriteString("\n") + for i, att := range results { + previewURL, err := urlrewrite.Rewrite(ctx, buildLargeAttachmentPreviewURL(brand, att.FileToken)) + if err != nil { + return "", err + } + sb.WriteString(att.FileName) + sb.WriteString("\n") + sb.WriteString(common.FormatSize(att.FileSize)) + sb.WriteString("\n") + sb.WriteString(downloadText + ": " + previewURL) + if i < len(results)-1 { + sb.WriteString("\n\n") + } else { + sb.WriteString("\n") + } + } + return sb.String(), nil +} + // fileTypeIcon returns the CDN icon filename for a given attachment filename, // matching desktop's AttachmentIconPath (mail-editor/src/plugins/bigAttachment/utils.ts). func fileTypeIcon(filename string) string { @@ -443,10 +537,16 @@ func processLargeAttachments( } if htmlBody != "" { - largeHTML := buildLargeAttachmentHTML(runtime.Config.Brand, resolveLang(runtime), results) + largeHTML, err := buildRewrittenLargeAttachmentHTML(ctx, runtime.Config.Brand, resolveLang(runtime), results) + if err != nil { + return bld, err + } bld = bld.HTMLBody([]byte(draftpkg.InsertBeforeQuoteOrAppend(htmlBody, largeHTML))) } else { - largeText := buildLargeAttachmentPlainText(runtime.Config.Brand, resolveLang(runtime), results) + largeText, err := buildRewrittenLargeAttachmentPlainText(ctx, runtime.Config.Brand, resolveLang(runtime), results) + if err != nil { + return bld, err + } bld = bld.TextBody([]byte(textBody + largeText)) } @@ -537,6 +637,61 @@ func ensureLargeAttachmentCards(runtime *common.RuntimeContext, snapshot *draftp } } +// ensureRewrittenLargeAttachmentCards is the command-path counterpart to +// ensureLargeAttachmentCards. It preserves the legacy helper for snapshot-only +// callers while ensuring newly generated links use the configured URL rewriter. +func ensureRewrittenLargeAttachmentCards(ctx context.Context, runtime *common.RuntimeContext, snapshot *draftpkg.DraftSnapshot) error { + summaries := draftpkg.ParseLargeAttachmentSummariesFromHeader(snapshot.Headers) + if len(summaries) == 0 { + return nil + } + + brand := core.BrandFeishu + if runtime.Config != nil { + brand = runtime.Config.Brand + } + lang := "zh_cn" + if runtime.Factory != nil { + lang = resolveLang(runtime) + } + + htmlPart := draftpkg.FindHTMLBodyPart(snapshot.Body) + if htmlPart != nil { + existingCards := draftpkg.ParseLargeAttachmentItemsFromHTML(string(htmlPart.Body)) + var missing []largeAttachmentResult + for _, s := range summaries { + if _, exists := existingCards[s.Token]; !exists { + missing = append(missing, largeAttachmentResult{FileName: s.FileName, FileSize: s.SizeBytes, FileToken: s.Token}) + } + } + if len(missing) == 0 { + return nil + } + return injectRewrittenLargeAttachmentHTMLIntoSnapshot(ctx, snapshot, brand, lang, missing) + } + + textPart := draftpkg.FindTextBodyPart(snapshot.Body) + if textPart == nil { + return nil + } + bodyText := string(textPart.Body) + var missing []largeAttachmentResult + for _, s := range summaries { + if !strings.Contains(bodyText, s.Token) { + missing = append(missing, largeAttachmentResult{FileName: s.FileName, FileSize: s.SizeBytes, FileToken: s.Token}) + } + } + if len(missing) == 0 { + return nil + } + largeText, err := buildRewrittenLargeAttachmentPlainText(ctx, brand, lang, missing) + if err != nil { + return err + } + injectLargeAttachmentTextIntoSnapshot(snapshot, largeText) + return nil +} + // preprocessLargeAttachmentsForDraftEdit scans a draft-edit patch for // add_attachment ops, classifies the files (normal vs oversized based on // the snapshot's current EML size), uploads oversized files, injects the @@ -551,7 +706,9 @@ func preprocessLargeAttachmentsForDraftEdit( // Reconstruct missing large attachment HTML cards from the server-format // header metadata. Must run before normalizeLargeAttachmentHeader which // discards file_name/file_size. - ensureLargeAttachmentCards(runtime, snapshot) + if err := ensureRewrittenLargeAttachmentCards(ctx, runtime, snapshot); err != nil { + return patch, err + } // Always normalize server-format headers to CLI format so every code // path below (and every early return) sends the format the server @@ -629,9 +786,14 @@ func preprocessLargeAttachmentsForDraftEdit( } if hasHTML { - injectLargeAttachmentHTMLIntoSnapshot(snapshot, runtime.Config.Brand, resolveLang(runtime), results) + if err := injectRewrittenLargeAttachmentHTMLIntoSnapshot(ctx, snapshot, runtime.Config.Brand, resolveLang(runtime), results); err != nil { + return patch, err + } } else { - largeText := buildLargeAttachmentPlainText(runtime.Config.Brand, resolveLang(runtime), results) + largeText, err := buildRewrittenLargeAttachmentPlainText(ctx, runtime.Config.Brand, resolveLang(runtime), results) + if err != nil { + return patch, err + } injectLargeAttachmentTextIntoSnapshot(snapshot, largeText) } @@ -765,6 +927,47 @@ func injectLargeAttachmentHTMLIntoSnapshot(snapshot *draftpkg.DraftSnapshot, bra htmlPart.Dirty = true } +func injectRewrittenLargeAttachmentHTMLIntoSnapshot(ctx context.Context, snapshot *draftpkg.DraftSnapshot, brand core.LarkBrand, lang string, results []largeAttachmentResult) error { + if len(results) == 0 { + return nil + } + htmlPart := draftpkg.FindHTMLBodyPart(snapshot.Body) + if htmlPart == nil { + if snapshot.Body != nil { + return nil + } + html, err := buildRewrittenLargeAttachmentHTML(ctx, brand, lang, results) + if err != nil { + return err + } + snapshot.Body = &draftpkg.Part{ + MediaType: "text/html", + Body: []byte(html), + Dirty: true, + } + return nil + } + + currentHTML := string(htmlPart.Body) + if draftpkg.HTMLContainsLargeAttachment(currentHTML) { + itemsHTML, err := buildRewrittenLargeAttachmentItems(ctx, brand, lang, results) + if err != nil { + return err + } + before, card, after := draftpkg.SplitAtLargeAttachment(currentHTML) + merged := card[:len(card)-len("")] + itemsHTML + "" + htmlPart.Body = []byte(before + merged + after) + } else { + fullHTML, err := buildRewrittenLargeAttachmentHTML(ctx, brand, lang, results) + if err != nil { + return err + } + htmlPart.Body = []byte(draftpkg.InsertBeforeQuoteOrAppend(currentHTML, fullHTML)) + } + htmlPart.Dirty = true + return nil +} + func injectLargeAttachmentTextIntoSnapshot(snapshot *draftpkg.DraftSnapshot, largeText string) { textPart := draftpkg.FindTextBodyPart(snapshot.Body) if textPart == nil { diff --git a/shortcuts/mail/large_attachment_test.go b/shortcuts/mail/large_attachment_test.go index a0aa34ada7..eedefc963e 100644 --- a/shortcuts/mail/large_attachment_test.go +++ b/shortcuts/mail/large_attachment_test.go @@ -4,6 +4,7 @@ package mail import ( + "context" "encoding/base64" "encoding/json" "os" @@ -12,6 +13,7 @@ import ( "github.com/spf13/cobra" + exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/larksuite/cli/shortcuts/common" @@ -19,6 +21,31 @@ import ( "github.com/larksuite/cli/shortcuts/mail/emlbuilder" ) +type largeAttachmentRewriteProvider struct { + rewriter exttransport.URLRewriter +} + +func (largeAttachmentRewriteProvider) Name() string { return "mail-url-rewrite" } + +func (largeAttachmentRewriteProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { + return nil +} + +func (p largeAttachmentRewriteProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { + return p.rewriter +} + +type largeAttachmentRewriteFunc func(string) string + +func (f largeAttachmentRewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } + +func withLargeAttachmentURLRewriter(t *testing.T, rewriter exttransport.URLRewriter) { + t.Helper() + previous := exttransport.GetProvider() + exttransport.Register(largeAttachmentRewriteProvider{rewriter: rewriter}) + t.Cleanup(func() { exttransport.Register(previous) }) +} + func TestEstimateBase64EMLSize(t *testing.T) { // 3 bytes raw → 4 bytes base64 + ~200 overhead got := estimateBase64EMLSize(3) @@ -120,6 +147,40 @@ func TestBuildLargeAttachmentPreviewURL(t *testing.T) { } } +func TestBuildRewrittenLargeAttachmentContent(t *testing.T) { + withLargeAttachmentURLRewriter(t, largeAttachmentRewriteFunc(func(rawURL string) string { + return strings.Replace(rawURL, "https://", "https://mirror.example/", 1) + })) + results := []largeAttachmentResult{{FileName: "report.pdf", FileSize: 1024, FileToken: "token"}} + + html, err := buildRewrittenLargeAttachmentHTML(context.Background(), core.BrandFeishu, "en_us", results) + if err != nil { + t.Fatalf("buildRewrittenLargeAttachmentHTML() error: %v", err) + } + if !strings.Contains(html, "https://mirror.example/www.feishu.cn/mail/page/attachment?token=token") { + t.Fatalf("HTML does not contain rewritten preview URL: %s", html) + } + if !strings.Contains(html, "https://mirror.example/lf-larkemail.bytetos.com/") { + t.Fatalf("HTML does not contain rewritten icon URL: %s", html) + } + + text, err := buildRewrittenLargeAttachmentPlainText(context.Background(), core.BrandFeishu, "en_us", results) + if err != nil { + t.Fatalf("buildRewrittenLargeAttachmentPlainText() error: %v", err) + } + if !strings.Contains(text, "https://mirror.example/www.feishu.cn/mail/page/attachment?token=token") { + t.Fatalf("text does not contain rewritten preview URL: %s", text) + } +} + +func TestBuildRewrittenLargeAttachmentContentRejectsInvalidURL(t *testing.T) { + withLargeAttachmentURLRewriter(t, largeAttachmentRewriteFunc(func(string) string { return "invalid" })) + results := []largeAttachmentResult{{FileName: "report.pdf", FileSize: 1024, FileToken: "token"}} + if _, err := buildRewrittenLargeAttachmentHTML(context.Background(), core.BrandFeishu, "en_us", results); err == nil { + t.Fatal("buildRewrittenLargeAttachmentHTML() error = nil, want invalid rewrite error") + } +} + func TestBuildLargeAttachmentHTML(t *testing.T) { results := []largeAttachmentResult{ {FileName: "report.pdf", FileSize: 50 * 1024 * 1024, FileToken: "tok_abc"}, diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 306b7db5d4..71b114eb39 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -472,10 +472,16 @@ var MailForward = common.Shortcut{ } if composedHTMLBody != "" { - largeHTML := buildLargeAttachmentHTML(runtime.Config.Brand, resolveLang(runtime), uploadResults) + largeHTML, err := buildRewrittenLargeAttachmentHTML(ctx, runtime.Config.Brand, resolveLang(runtime), uploadResults) + if err != nil { + return err + } bld = bld.HTMLBody([]byte(draftpkg.InsertBeforeQuoteOrAppend(composedHTMLBody, largeHTML))) } else { - largeText := buildLargeAttachmentPlainText(runtime.Config.Brand, resolveLang(runtime), uploadResults) + largeText, err := buildRewrittenLargeAttachmentPlainText(ctx, runtime.Config.Brand, resolveLang(runtime), uploadResults) + if err != nil { + return err + } bld = bld.TextBody([]byte(composedTextBody + largeText)) } diff --git a/shortcuts/okr/okr_progress_create.go b/shortcuts/okr/okr_progress_create.go index 3a56d5d9df..8014f55551 100644 --- a/shortcuts/okr/okr_progress_create.go +++ b/shortcuts/okr/okr_progress_create.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/shortcuts/common" ) @@ -78,7 +79,11 @@ func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createPro sourceURL := runtime.Str("source-url") if sourceURL == "" { - sourceURL = core.ResolveOpenBaseURL(runtime.Config.Brand) + "/app" + var err error + sourceURL, err = urlrewrite.Rewrite(runtime.Ctx(), core.ResolveOpenBaseURL(runtime.Config.Brand)+"/app") + if err != nil { + return nil, err + } } var progressRate *ProgressRateV1 diff --git a/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go index 41c077d6ee..42affd5c66 100644 --- a/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go +++ b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go @@ -181,7 +181,9 @@ var SheetCreate = common.Shortcut{ url, _ := spreadsheet["url"].(string) if url = strings.TrimSpace(url); url != "" { out["url"] = url - } else if u := common.BuildResourceURL(runtime.Config.Brand, "sheet", token); u != "" { + } else if u, err := common.BuildResourceURL(ctx, runtime.Config.Brand, "sheet", token); err != nil { + return err + } else if u != "" { out["url"] = u } if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil { diff --git a/shortcuts/slides/slides_create.go b/shortcuts/slides/slides_create.go index 56b5684676..d254b44d82 100644 --- a/shortcuts/slides/slides_create.go +++ b/shortcuts/slides/slides_create.go @@ -213,7 +213,10 @@ var SlidesCreate = common.Shortcut{ // brand-standard URL only when the API omits it. presentationURL := common.GetString(data, "url") if presentationURL == "" { - presentationURL = common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID) + presentationURL, err = common.BuildResourceURL(ctx, runtime.Config.Brand, "slides", presentationID) + if err != nil { + return err + } } if presentationURL != "" { result["url"] = presentationURL diff --git a/shortcuts/vc/helpers.go b/shortcuts/vc/helpers.go index 62eb71b934..59df77384a 100644 --- a/shortcuts/vc/helpers.go +++ b/shortcuts/vc/helpers.go @@ -38,7 +38,10 @@ func normalizeMeetingQueryPermissionError(runtime *common.RuntimeContext, err er permissionErr.WithHint("ask the app developer to enable scope %s", meetingQueryBotScope) permissionErr.WithMissingScopes(meetingQueryBotScope).WithIdentity(string(core.AsBot)) if runtime.Config != nil { - consoleURL := registry.BuildConsoleScopeURL(runtime.Config.Brand, runtime.Config.AppID, meetingQueryBotScope) + consoleURL, rewriteErr := registry.BuildConsoleScopeURL(runtime.Ctx(), runtime.Config.Brand, runtime.Config.AppID, meetingQueryBotScope) + if rewriteErr != nil { + return rewriteErr + } if consoleURL != "" { permissionErr.WithConsoleURL(consoleURL) } diff --git a/shortcuts/wiki/wiki_helpers.go b/shortcuts/wiki/wiki_helpers.go index 59f10dc301..83a1a41c5f 100644 --- a/shortcuts/wiki/wiki_helpers.go +++ b/shortcuts/wiki/wiki_helpers.go @@ -4,6 +4,7 @@ package wiki import ( + "context" "strings" "github.com/larksuite/cli/errs" @@ -18,14 +19,14 @@ import ( // // Shared by +node-create and +node-copy, hence kept here rather than in either // command's file. -func wikiNodeURL(brand core.LarkBrand, node *wikiNodeRecord) string { +func wikiNodeURL(ctx context.Context, brand core.LarkBrand, node *wikiNodeRecord) (string, error) { if node == nil { - return "" + return "", nil } if u := strings.TrimSpace(node.URL); u != "" { - return u + return u, nil } - return common.BuildResourceURL(brand, "wiki", node.NodeToken) + return common.BuildResourceURL(ctx, brand, "wiki", node.NodeToken) } func appendWikiProblemHint(err error, hint string) error { diff --git a/shortcuts/wiki/wiki_node_copy.go b/shortcuts/wiki/wiki_node_copy.go index a6632515be..6a3235c8aa 100644 --- a/shortcuts/wiki/wiki_node_copy.go +++ b/shortcuts/wiki/wiki_node_copy.go @@ -107,7 +107,9 @@ var WikiNodeCopy = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Copied to node %s in space %s\n", common.MaskToken(node.NodeToken), common.MaskToken(node.SpaceID)) out := wikiNodeCopyOutput(node) - if u := wikiNodeURL(runtime.Config.Brand, node); u != "" { + if u, err := wikiNodeURL(ctx, runtime.Config.Brand, node); err != nil { + return err + } else if u != "" { out["url"] = u } runtime.OutFormat(out, nil, func(w io.Writer) { diff --git a/shortcuts/wiki/wiki_node_create.go b/shortcuts/wiki/wiki_node_create.go index d5ff12e298..cd3fb4cfbc 100644 --- a/shortcuts/wiki/wiki_node_create.go +++ b/shortcuts/wiki/wiki_node_create.go @@ -95,7 +95,11 @@ var WikiNodeCreate = common.Shortcut{ } fmt.Fprintf(runtime.IO().ErrOut, "Created wiki node in space %s via %s.\n", execution.ResolvedSpace.SpaceID, execution.ResolvedSpace.ResolvedBy) - runtime.Out(augmentWikiNodeCreateOutput(runtime, execution), nil) + out, err := augmentWikiNodeCreateOutput(ctx, runtime, execution) + if err != nil { + return err + } + runtime.Out(out, nil) return nil }, } @@ -596,17 +600,19 @@ func wikiNodeCreateOutput(execution *wikiNodeCreateExecution) map[string]interfa } } -func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wikiNodeCreateExecution) map[string]interface{} { +func augmentWikiNodeCreateOutput(ctx context.Context, runtime *common.RuntimeContext, execution *wikiNodeCreateExecution) (map[string]interface{}, error) { if execution == nil || execution.Node == nil { - return map[string]interface{}{} + return map[string]interface{}{}, nil } out := wikiNodeCreateOutput(execution) if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil { out["permission_grant"] = grant } - if u := wikiNodeURL(runtime.Config.Brand, execution.Node); u != "" { + if u, err := wikiNodeURL(ctx, runtime.Config.Brand, execution.Node); err != nil { + return nil, err + } else if u != "" { out["url"] = u } - return out + return out, nil } diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index 9b1bb1a424..37d10762c7 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -846,11 +846,11 @@ func TestWikiNodeCreateUserSkipsPermissionGrantAugmentation(t *testing.T) { func TestAugmentWikiNodeCreateOutputReturnsEmptyMapForNilInput(t *testing.T) { t.Parallel() - if got := augmentWikiNodeCreateOutput(nil, nil); len(got) != 0 { + if got, err := augmentWikiNodeCreateOutput(context.Background(), nil, nil); err != nil || len(got) != 0 { t.Fatalf("augmentWikiNodeCreateOutput(nil, nil) = %#v, want empty map", got) } - if got := augmentWikiNodeCreateOutput(nil, &wikiNodeCreateExecution{}); len(got) != 0 { + if got, err := augmentWikiNodeCreateOutput(context.Background(), nil, &wikiNodeCreateExecution{}); err != nil || len(got) != 0 { t.Fatalf("augmentWikiNodeCreateOutput(nil, empty execution) = %#v, want empty map", got) } } @@ -892,7 +892,11 @@ func TestWikiNodeURL(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if got := wikiNodeURL(core.BrandFeishu, tc.node); got != tc.want { + got, err := wikiNodeURL(context.Background(), core.BrandFeishu, tc.node) + if err != nil { + t.Fatalf("wikiNodeURL() error = %v", err) + } + if got != tc.want { t.Fatalf("wikiNodeURL() = %q, want %q", got, tc.want) } }) From ee8793e002aa1bcbc27168bddf0c1694dced3e86 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:19:49 +0800 Subject: [PATCH 05/18] fix: synchronize rewritten request host --- internal/transport/extension.go | 1 + internal/transport/extension_test.go | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/transport/extension.go b/internal/transport/extension.go index a73dacd5fb..699881d845 100644 --- a/internal/transport/extension.go +++ b/internal/transport/extension.go @@ -116,6 +116,7 @@ func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, erro ) } req.URL = rewrittenURL + req.Host = rewrittenURL.Host } } diff --git a/internal/transport/extension_test.go b/internal/transport/extension_test.go index c2d8212db5..decd9a9abb 100644 --- a/internal/transport/extension_test.go +++ b/internal/transport/extension_test.go @@ -215,10 +215,11 @@ func TestHTTPPolicyRouterRewriteOnlyProviderDoesNotMutateCaller(t *testing.T) { }) t.Cleanup(func() { exttransport.Register(previousProvider) }) - var baseURL string + var baseURL, baseHost string router := NewHTTPPolicyRouter( roundTripFunc(func(req *http.Request) (*http.Response, error) { baseURL = req.URL.String() + baseHost = req.Host return noContentResponse(req), nil }), roundTripFunc(func(*http.Request) (*http.Response, error) { @@ -243,9 +244,15 @@ func TestHTTPPolicyRouterRewriteOnlyProviderDoesNotMutateCaller(t *testing.T) { if baseURL != rewrittenURL { t.Fatalf("base URL = %q, want %q", baseURL, rewrittenURL) } + if baseHost != "mirror.example.test" { + t.Fatalf("base Host = %q, want rewritten host", baseHost) + } if got := req.URL.String(); got != originalURL { t.Fatalf("caller request URL = %q, want %q", got, originalURL) } + if got := req.Host; got != "source.example.test" { + t.Fatalf("caller request Host = %q, want original host", got) + } } func TestHTTPPolicyRouterInterceptorObservesRewrittenURL(t *testing.T) { From 0fe36b463189f1e7973670fadccf0c244ec9e8ef Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:14:13 +0800 Subject: [PATCH 06/18] feat: add manifest-based distribution updates --- cmd/doctor/doctor.go | 4 +- cmd/doctor/doctor_test.go | 30 ++ cmd/update/manifest.go | 154 +++++++ cmd/update/update.go | 23 +- cmd/update/update_test.go | 118 ++++++ extension/transport/registry_test.go | 26 ++ extension/transport/types.go | 18 + internal/distribution/archive.go | 147 +++++++ internal/distribution/archive_test.go | 105 +++++ internal/distribution/config.go | 54 +++ internal/distribution/download.go | 74 ++++ internal/distribution/download_test.go | 51 +++ internal/distribution/manifest.go | 177 ++++++++ internal/distribution/manifest_test.go | 96 +++++ internal/distribution/prepare.go | 113 +++++ internal/distribution/prepare_test.go | 28 ++ internal/distributioninstall/install.go | 423 +++++++++++++++++++ internal/distributioninstall/install_test.go | 194 +++++++++ internal/skillscheck/state.go | 26 ++ internal/update/update.go | 99 ++++- internal/update/update_test.go | 41 ++ 21 files changed, 1989 insertions(+), 12 deletions(-) create mode 100644 cmd/update/manifest.go create mode 100644 internal/distribution/archive.go create mode 100644 internal/distribution/archive_test.go create mode 100644 internal/distribution/config.go create mode 100644 internal/distribution/download.go create mode 100644 internal/distribution/download_test.go create mode 100644 internal/distribution/manifest.go create mode 100644 internal/distribution/manifest_test.go create mode 100644 internal/distribution/prepare.go create mode 100644 internal/distribution/prepare_test.go create mode 100644 internal/distributioninstall/install.go create mode 100644 internal/distributioninstall/install_test.go diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index 6e4a621933..ef1013fa7d 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -241,7 +241,7 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error { return nil } -// checkCLIUpdate actively queries the npm registry for the latest version. +// checkCLIUpdate actively queries the configured source for its target version. // Unlike the root-level async check, this does a synchronous fetch with timeout // and works regardless of build version (dev builds included). func checkCLIUpdate() []checkResult { @@ -250,7 +250,7 @@ func checkCLIUpdate() []checkResult { return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")} } current := build.Version - if update.IsNewer(latest, current) { + if update.IsUpdateAvailable(latest, current) { return []checkResult{warn("cli_update", fmt.Sprintf("%s → %s available", current, latest), "run: lark-cli update")} diff --git a/cmd/doctor/doctor_test.go b/cmd/doctor/doctor_test.go index 6da687089f..ffec1cdbcd 100644 --- a/cmd/doctor/doctor_test.go +++ b/cmd/doctor/doctor_test.go @@ -14,6 +14,8 @@ import ( "github.com/spf13/cobra" extcred "github.com/larksuite/cli/extension/credential" + exttransport "github.com/larksuite/cli/extension/transport" + "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" @@ -21,6 +23,34 @@ import ( "github.com/larksuite/cli/internal/surface" ) +type doctorManifestProvider struct{} + +func (doctorManifestProvider) Name() string { return "doctor-manifest-test" } +func (doctorManifestProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { + return nil +} +func (doctorManifestProvider) ResolveDistribution(context.Context) exttransport.DistributionConfig { + return exttransport.DistributionConfig{ManifestURL: "https://dist.example/manifest.json"} +} + +func TestCheckCLIUpdateReportsDifferentOpaqueManifestTarget(t *testing.T) { + previousProvider := exttransport.GetProvider() + previousFetch := fetchLatestForDoctor + previousVersion := build.Version + exttransport.Register(doctorManifestProvider{}) + fetchLatestForDoctor = func() (string, error) { return "older-channel", nil } + build.Version = "newer-channel" + t.Cleanup(func() { + exttransport.Register(previousProvider) + fetchLatestForDoctor = previousFetch + build.Version = previousVersion + }) + checks := checkCLIUpdate() + if len(checks) != 1 || checks[0].Status != "warn" || !strings.Contains(checks[0].Message, "older-channel") { + t.Fatalf("checks = %#v", checks) + } +} + func TestNewCmdDoctor_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, diff --git a/cmd/update/manifest.go b/cmd/update/manifest.go new file mode 100644 index 0000000000..d90e323a99 --- /dev/null +++ b/cmd/update/manifest.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdupdate + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "net" + "os" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/distribution" + "github.com/larksuite/cli/internal/distributioninstall" + "github.com/larksuite/cli/internal/output" +) + +func runManifestUpdate(ctx context.Context, opts *UpdateOptions, source distribution.Source) error { + streams := opts.Factory.IOStreams + current := currentVersion() + manifest, err := distribution.FetchManifest(ctx, source) + if err != nil { + return reportDistributionError(opts, "failed to load distribution manifest", err) + } + target := manifest.Version + if opts.Check { + return reportManifestCheck(opts, current, target) + } + if !opts.Force && target == current { + return reportManifestCurrent(opts, current) + } + if !opts.JSON { + fmt.Fprintf(streams.ErrOut, "Updating lark-cli %s %s %s from the configured distribution ...\n", current, symArrow(), target) + } + prepared, err := distribution.PrepareUpdate(ctx, manifest) + if err != nil { + return reportDistributionError(opts, "failed to prepare distribution update", err) + } + defer prepared.Cleanup() + if err := distributioninstall.InstallPrepared(prepared, distributioninstall.InstallOptions{}); err != nil { + return reportError(opts, streams, "update_error", + errs.NewInternalError(errs.SubtypeUnknown, "failed to install distribution update: %s", err). + WithHint("Retry with `lark-cli update --force`.").WithCause(err)) + } + if opts.JSON { + output.PrintJson(streams.Out, map[string]interface{}{ + "ok": true, "source": "manifest", + "previous_version": current, "current_version": target, "target_version": target, + "action": "updated", "skills_action": "synced", + "message": fmt.Sprintf("lark-cli updated from %s to %s", current, target), + }) + return nil + } + fmt.Fprintf(streams.ErrOut, "\n%s Successfully updated lark-cli and Skills from %s to %s\n", symOK(), current, target) + return nil +} + +func reportManifestCheck(opts *UpdateOptions, current, target string) error { + streams := opts.Factory.IOStreams + action := "already_up_to_date" + message := fmt.Sprintf("lark-cli %s matches the configured target", current) + if current != target { + action = "update_available" + message = fmt.Sprintf("lark-cli %s %s configured target %s", current, symArrow(), target) + } + if opts.JSON { + output.PrintJson(streams.Out, map[string]interface{}{ + "ok": true, "source": "manifest", + "previous_version": current, "current_version": current, "target_version": target, + "action": action, "auto_update": true, "message": message, + }) + return nil + } + if current == target { + fmt.Fprintf(streams.ErrOut, "%s %s\n", symOK(), message) + } else { + fmt.Fprintf(streams.ErrOut, "Configured target: %s %s %s\n\nRun `lark-cli update` to install.\n", current, symArrow(), target) + } + return nil +} + +func reportManifestCurrent(opts *UpdateOptions, current string) error { + streams := opts.Factory.IOStreams + if opts.JSON { + output.PrintJson(streams.Out, map[string]interface{}{ + "ok": true, "source": "manifest", + "previous_version": current, "current_version": current, "target_version": current, + "action": "already_up_to_date", + "message": fmt.Sprintf("lark-cli %s matches the configured target", current), + }) + return nil + } + fmt.Fprintf(streams.ErrOut, "%s lark-cli %s matches the configured target\n", symOK(), current) + return nil +} + +func reportDistributionError(opts *UpdateOptions, message string, err error) error { + typed := classifyDistributionError(message, err) + errType := "update_error" + if problem, ok := errs.ProblemOf(typed); ok && problem.Category == errs.CategoryNetwork { + errType = "network" + } + return reportError(opts, opts.Factory.IOStreams, errType, typed) +} + +func classifyDistributionError(message string, err error) errs.TypedError { + var typed errs.TypedError + if errors.As(err, &typed) { + return typed + } + var pathErr *os.PathError + if errors.As(err, &pathErr) { + return errs.NewInternalError(errs.SubtypeFileIO, "%s", message).WithCause(err) + } + if status, ok := distribution.HTTPStatusCode(err); ok { + subtype := errs.SubtypeNetworkProtocol + retryable := false + switch { + case status == 408: + subtype, retryable = errs.SubtypeNetworkTimeout, true + case status >= 500: + subtype, retryable = errs.SubtypeNetworkServer, true + } + networkErr := errs.NewNetworkError(subtype, "%s", message).WithCode(status).WithCause(err) + if retryable { + networkErr.WithRetryable() + } + return networkErr + } + subtype := errs.SubtypeNetworkProtocol + retryable := false + var netErr net.Error + var dnsErr *net.DNSError + var authorityErr x509.UnknownAuthorityError + lower := strings.ToLower(err.Error()) + switch { + case errors.Is(err, context.DeadlineExceeded), errors.As(err, &netErr) && netErr.Timeout(): + subtype, retryable = errs.SubtypeNetworkTimeout, true + case errors.As(err, &authorityErr), strings.Contains(lower, "x509:"), strings.Contains(lower, "tls:"): + subtype = errs.SubtypeNetworkTLS + case errors.As(err, &dnsErr): + subtype, retryable = errs.SubtypeNetworkDNS, true + case errors.As(err, &netErr): + subtype, retryable = errs.SubtypeNetworkTransport, true + } + networkErr := errs.NewNetworkError(subtype, "%s", message).WithCause(err) + if retryable && !errors.Is(err, context.Canceled) { + networkErr.WithRetryable() + } + return networkErr +} diff --git a/cmd/update/update.go b/cmd/update/update.go index 8c0d851f0a..f7e9ed2b8e 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -16,6 +16,7 @@ import ( "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/distribution" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/selfupdate" "github.com/larksuite/cli/internal/skillscheck" @@ -103,10 +104,11 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "update", - Short: "Update lark-cli to the latest version", - Long: `Update lark-cli to the latest version. + Short: "Update lark-cli and its managed Skills", + Long: `Update lark-cli using the active update source. Detects the installation method automatically: + - configured distribution: installs checksum-verified CLI and Skills artifacts - npm install: runs npm install -g @larksuite/cli@ - pnpm install: runs pnpm add -g @larksuite/cli@ - manual/other: shows GitHub Releases download URL @@ -134,6 +136,9 @@ func updateRun(opts *UpdateOptions) error { } func updateRunWithContext(ctx context.Context, opts *UpdateOptions) error { + if ctx == nil { + ctx = context.Background() + } io := opts.Factory.IOStreams if _, err := skillscheck.ParseLayout(opts.SkillsLayout); err != nil { return reportError(opts, io, "validation", @@ -145,6 +150,20 @@ func updateRunWithContext(ctx context.Context, opts *UpdateOptions) error { WithParam("--skills-layout"). WithHint("Remove --skills-layout when using --check.")) } + source, manifestMode, err := distribution.ResolveSource(ctx) + if err != nil { + return reportError(opts, io, "configuration", + errs.NewConfigError(errs.SubtypeInvalidConfig, "invalid distribution configuration: %s", err).WithCause(err)) + } + if manifestMode { + if strings.TrimSpace(opts.SkillsLayout) != "" { + return reportError(opts, io, "validation", + errs.NewValidationError(errs.SubtypeInvalidArgument, "--skills-layout is not supported by the configured distribution"). + WithParam("--skills-layout")) + } + output.PendingNotice = nil + return runManifestUpdate(ctx, opts, source) + } cur := currentVersion() updater := newUpdater() // Brand only steers skills sync. updateRun skips that resolution in --check, diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index e3bf14cf7a..5a10b118b7 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -6,12 +6,17 @@ package cmdupdate import ( "bytes" "context" + "crypto/sha256" "encoding/json" "errors" "fmt" + "net" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" "time" @@ -20,6 +25,7 @@ import ( exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/distribution" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/selfupdate" "github.com/larksuite/cli/internal/skillscheck" @@ -39,6 +45,118 @@ func (updateURLRewriteProvider) ResolveURLRewriter(context.Context) exttransport return updateURLRewriter{} } +type updateManifestProvider struct{ manifestURL string } + +func (p updateManifestProvider) Name() string { return "test-manifest" } +func (p updateManifestProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { + return nil +} +func (p updateManifestProvider) ResolveDistribution(context.Context) exttransport.DistributionConfig { + return exttransport.DistributionConfig{ManifestURL: p.manifestURL} +} + +func TestManifestCheckAcceptsHTTPAndReportsOpaqueDowngradeTarget(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"schema":1,"version":"older-channel","artifacts":{"skills":{"url":"https://dist.example/skills","checksum":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},%q:{"url":"https://dist.example/binary","checksum":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}}`, runtime.GOOS+"-"+runtime.GOARCH) + })) + defer server.Close() + previousProvider := exttransport.GetProvider() + previousClient := distribution.DefaultClient + previousVersion := currentVersion + exttransport.Register(updateManifestProvider{manifestURL: server.URL}) + distribution.DefaultClient = server.Client() + currentVersion = func() string { return "newer-channel" } + t.Cleanup(func() { + exttransport.Register(previousProvider) + distribution.DefaultClient = previousClient + currentVersion = previousVersion + }) + + factory, stdout, _ := newTestFactory(t) + err := updateRunWithContext(context.Background(), &UpdateOptions{Factory: factory, JSON: true, Check: true}) + if err != nil { + t.Fatal(err) + } + var got map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["action"] != "update_available" || got["target_version"] != "older-channel" || got["source"] != "manifest" { + t.Fatalf("output = %#v", got) + } + if _, exists := got["latest_version"]; exists { + t.Fatalf("manifest output must not label an arbitrary target as latest: %#v", got) + } +} + +func TestClassifyDistributionError(t *testing.T) { + tests := []struct { + name string + err error + category errs.Category + subtype errs.Subtype + retryable bool + }{ + {name: "timeout", err: context.DeadlineExceeded, category: errs.CategoryNetwork, subtype: errs.SubtypeNetworkTimeout, retryable: true}, + {name: "dns", err: &net.DNSError{Err: "lookup failed", Name: "dist.example"}, category: errs.CategoryNetwork, subtype: errs.SubtypeNetworkDNS, retryable: true}, + {name: "file IO", err: &os.PathError{Op: "mkdir", Path: "/tmp/config", Err: os.ErrPermission}, category: errs.CategoryInternal, subtype: errs.SubtypeFileIO}, + {name: "bad archive", err: errors.New("unsupported archive format"), category: errs.CategoryNetwork, subtype: errs.SubtypeNetworkProtocol}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyDistributionError("distribution failed", tt.err) + problem, ok := errs.ProblemOf(got) + if !ok || problem.Category != tt.category || problem.Subtype != tt.subtype || problem.Retryable != tt.retryable { + t.Fatalf("problem = %#v, want category=%q subtype=%q retryable=%v", problem, tt.category, tt.subtype, tt.retryable) + } + if !errors.Is(got, tt.err) { + t.Fatalf("cause %v was not preserved", tt.err) + } + }) + } +} + +func TestManifestArtifactProtocolFailureUsesNetworkTaxonomy(t *testing.T) { + payload := []byte("not an archive") + digest := fmt.Sprintf("sha256:%x", sha256.Sum256(payload)) + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/manifest.json" { + _, _ = w.Write(payload) + return + } + artifactURL := server.URL + "/artifact" + fmt.Fprintf(w, `{"schema":1,"version":"target","artifacts":{"skills":{"url":%q,"checksum":%q},%q:{"url":%q,"checksum":%q}}}`, + artifactURL, digest, distribution.CurrentPlatformKey(), artifactURL, digest) + })) + defer server.Close() + + previousProvider := exttransport.GetProvider() + previousClient := distribution.DefaultClient + previousVersion := currentVersion + exttransport.Register(updateManifestProvider{manifestURL: server.URL + "/manifest.json"}) + distribution.DefaultClient = server.Client() + currentVersion = func() string { return "current" } + t.Cleanup(func() { + exttransport.Register(previousProvider) + distribution.DefaultClient = previousClient + currentVersion = previousVersion + }) + + factory, stdout, _ := newTestFactory(t) + if err := updateRunWithContext(context.Background(), &UpdateOptions{Factory: factory, JSON: true}); err == nil { + t.Fatal("update succeeded") + } + var got map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatal(err) + } + problem, _ := got["error"].(map[string]interface{}) + if problem["type"] != "network" { + t.Fatalf("output = %#v", got) + } +} + type updateURLRewriter struct{} func (updateURLRewriter) RewriteURL(rawURL string) string { diff --git a/extension/transport/registry_test.go b/extension/transport/registry_test.go index eff628207c..30a614ea86 100644 --- a/extension/transport/registry_test.go +++ b/extension/transport/registry_test.go @@ -35,6 +35,15 @@ type stubURLRewriter func(string) string func (f stubURLRewriter) RewriteURL(rawURL string) string { return f(rawURL) } +type stubDistributionProvider struct { + stubProvider + config DistributionConfig +} + +func (s *stubDistributionProvider) ResolveDistribution(context.Context) DistributionConfig { + return s.config +} + func TestGetProvider_NilByDefault(t *testing.T) { mu.Lock() provider = nil @@ -108,3 +117,20 @@ func TestURLRewriterProviderIsOptional(t *testing.T) { t.Fatalf("RewriteURL() = %q", got) } } + +func TestDistributionProviderIsOptional(t *testing.T) { + previous := GetProvider() + t.Cleanup(func() { Register(previous) }) + p := &stubDistributionProvider{ + stubProvider: stubProvider{name: "distribution"}, + config: DistributionConfig{ManifestURL: "https://dist.example/manifest.json"}, + } + Register(p) + configured, ok := GetProvider().(DistributionProvider) + if !ok { + t.Fatal("registered provider does not implement DistributionProvider") + } + if got := configured.ResolveDistribution(context.Background()).ManifestURL; got != p.config.ManifestURL { + t.Fatalf("ManifestURL = %q", got) + } +} diff --git a/extension/transport/types.go b/extension/transport/types.go index 6614a50f79..099e29ff1d 100644 --- a/extension/transport/types.go +++ b/extension/transport/types.go @@ -29,6 +29,24 @@ type URLRewriterProvider interface { ResolveURLRewriter(ctx context.Context) URLRewriter } +// DistributionConfig selects a fixed distribution manifest. Manifest and +// artifact URLs are final download addresses; the CLI does not pass them +// through URL rewriting or the request interceptor. HTTP is supported for +// trusted distribution networks; the provider is responsible for transport +// integrity when it does not use HTTPS. +type DistributionConfig struct { + ManifestURL string + _ struct{} +} + +// DistributionProvider optionally supplies a distribution manifest in +// addition to the existing request interceptor. Providers that do not +// implement this interface retain the package-manager update flow. +type DistributionProvider interface { + Provider + ResolveDistribution(ctx context.Context) DistributionConfig +} + // RequestClass describes the trust boundary of an outbound HTTP request. // Platform requests target endpoints owned by the CLI's endpoint resolver; // external requests target user-provided, pre-signed, CDN, registry, or other diff --git a/internal/distribution/archive.go b/internal/distribution/archive.go new file mode 100644 index 0000000000..0e3086bccc --- /dev/null +++ b/internal/distribution/archive.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/larksuite/cli/internal/vfs" +) + +// artifactExtractedMaxBytes allows expected expansion while bounding the +// temporary disk consumed by one bundle. +const artifactExtractedMaxBytes int64 = 8 << 30 + +func extractArchive(archivePath, destination string) error { + return extractArchiveWithLimit(archivePath, destination, artifactExtractedMaxBytes) +} + +func extractArchiveWithLimit(archivePath, destination string, maxBytes int64) error { + file, err := vfs.Open(archivePath) + if err != nil { + return err + } + defer file.Close() + + header := make([]byte, 4) + n, readErr := io.ReadFull(file, header) + if readErr != nil && readErr != io.ErrUnexpectedEOF { + return readErr + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + + switch { + case n >= 2 && header[0] == 0x1f && header[1] == 0x8b: + return extractTarGzip(file, destination, maxBytes) + case n >= 4 && string(header[:4]) == "PK\x03\x04": + info, err := file.Stat() + if err != nil { + return err + } + reader, err := zip.NewReader(file, info.Size()) + if err != nil { + return err + } + return extractZip(reader, destination, maxBytes) + default: + return fmt.Errorf("unsupported distribution archive format") + } +} + +func extractTarGzip(source io.Reader, destination string, maxBytes int64) error { + gzipReader, err := gzip.NewReader(source) + if err != nil { + return err + } + defer gzipReader.Close() + + reader := tar.NewReader(gzipReader) + var total int64 + for { + header, err := reader.Next() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + switch header.Typeflag { + case tar.TypeDir: + if err := vfs.MkdirAll(filepath.Join(destination, filepath.FromSlash(header.Name)), 0o755); err != nil { + return err + } + case tar.TypeReg, tar.TypeRegA: + if header.Size < 0 || header.Size > maxBytes-total { + return fmt.Errorf("extracted artifact exceeds %d bytes", maxBytes) + } + if err := writeArchiveFile(destination, header.Name, header.FileInfo().Mode(), reader); err != nil { + return err + } + total += header.Size + } + } +} + +func extractZip(reader *zip.Reader, destination string, maxBytes int64) error { + var total int64 + for _, entry := range reader.File { + if entry.FileInfo().IsDir() { + if err := vfs.MkdirAll(filepath.Join(destination, filepath.FromSlash(entry.Name)), 0o755); err != nil { + return err + } + continue + } + if !entry.Mode().IsRegular() { + continue + } + if entry.UncompressedSize64 > uint64(maxBytes-total) { + return fmt.Errorf("extracted artifact exceeds %d bytes", maxBytes) + } + source, err := entry.Open() + if err != nil { + return err + } + writeErr := writeArchiveFile(destination, entry.Name, entry.Mode(), source) + closeErr := source.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + total += int64(entry.UncompressedSize64) + } + return nil +} + +func writeArchiveFile(root, name string, mode os.FileMode, source io.Reader) error { + target := filepath.Join(root, filepath.FromSlash(name)) + if err := vfs.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + perm := mode.Perm() + if perm&0o111 != 0 { + perm = 0o755 + } else { + perm = 0o644 + } + file, err := vfs.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm) + if err != nil { + return err + } + _, copyErr := io.Copy(file, source) + closeErr := file.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} diff --git a/internal/distribution/archive_test.go b/internal/distribution/archive_test.go new file mode 100644 index 0000000000..97627bc41d --- /dev/null +++ b/internal/distribution/archive_test.go @@ -0,0 +1,105 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExtractArchiveFormats(t *testing.T) { + tests := []struct { + name string + build func(*testing.T, string) + }{ + {"tar.gz", writeTestTarGzip}, + {"zip", writeTestZip}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + archive := filepath.Join(root, "artifact") + tt.build(t, archive) + destination := filepath.Join(root, "out") + if err := extractArchive(archive, destination); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(destination, "skill", "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if string(got) != "content" { + t.Fatalf("content = %q", got) + } + }) + } +} + +func TestExtractArchiveRejectsExcessiveExpandedSize(t *testing.T) { + for _, tt := range []struct { + name string + build func(*testing.T, string) + }{ + {"tar.gz", writeTestTarGzip}, + {"zip", writeTestZip}, + } { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + archive := filepath.Join(root, "artifact") + tt.build(t, archive) + err := extractArchiveWithLimit(archive, filepath.Join(root, "out"), 6) + if err == nil || !strings.Contains(err.Error(), "exceeds 6 bytes") { + t.Fatalf("err = %v", err) + } + }) + } +} + +func writeTestTarGzip(t *testing.T, path string) { + t.Helper() + var data bytes.Buffer + gz := gzip.NewWriter(&data) + tw := tar.NewWriter(gz) + content := []byte("content") + if err := tw.WriteHeader(&tar.Header{Name: "skill/SKILL.md", Mode: 0o644, Size: int64(len(content))}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data.Bytes(), 0o600); err != nil { + t.Fatal(err) + } +} + +func writeTestZip(t *testing.T, path string) { + t.Helper() + var data bytes.Buffer + zw := zip.NewWriter(&data) + entry, err := zw.Create("skill/SKILL.md") + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte("content")); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data.Bytes(), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/distribution/config.go b/internal/distribution/config.go new file mode 100644 index 0000000000..3a1a21bdd8 --- /dev/null +++ b/internal/distribution/config.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "context" + "fmt" + "net/url" + "strings" + + exttransport "github.com/larksuite/cli/extension/transport" +) + +// Source is the validated configured distribution source. +type Source struct { + ManifestURL string +} + +// ResolveSource returns the configured distribution source. The boolean is +// false when the active transport provider does not opt into manifest-based +// distribution or returns an empty URL. +func ResolveSource(ctx context.Context) (Source, bool, error) { + provider := exttransport.GetProvider() + configured, ok := provider.(exttransport.DistributionProvider) + if !ok { + return Source{}, false, nil + } + raw := strings.TrimSpace(configured.ResolveDistribution(ctx).ManifestURL) + if raw == "" { + return Source{}, false, nil + } + if err := validateDistributionURL(raw); err != nil { + return Source{}, false, fmt.Errorf("invalid distribution manifest URL: %w", err) + } + return Source{ManifestURL: raw}, true, nil +} + +func validateDistributionURL(raw string) error { + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("must be a valid URL") + } + if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return fmt.Errorf("must be an absolute HTTP or HTTPS URL") + } + if parsed.User != nil { + return fmt.Errorf("must not contain user information") + } + if parsed.Fragment != "" { + return fmt.Errorf("must not contain a fragment") + } + return nil +} diff --git a/internal/distribution/download.go b/internal/distribution/download.go new file mode 100644 index 0000000000..208b3ab606 --- /dev/null +++ b/internal/distribution/download.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "strings" + + "github.com/larksuite/cli/internal/vfs" +) + +// These ceilings bound temporary disk use while leaving ample room for the +// CLI and Skills bundles. Raise them only when a supported bundle outgrows +// the current distribution contract. +const artifactDownloadMaxBytes int64 = 4 << 30 + +func downloadArtifact(ctx context.Context, artifact Artifact, directory, pattern string) (string, error) { + return downloadArtifactWithLimit(ctx, artifact, directory, pattern, artifactDownloadMaxBytes) +} + +func downloadArtifactWithLimit(ctx context.Context, artifact Artifact, directory, pattern string, maxBytes int64) (string, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, artifact.URL, nil) + if err != nil { + return "", err + } + response, err := httpClient().Do(request) + if err != nil { + return "", redactRequestError(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", newHTTPStatusError("artifact download", response.StatusCode) + } + if response.ContentLength > maxBytes { + return "", fmt.Errorf("artifact download exceeds %d bytes", maxBytes) + } + temporary, err := vfs.CreateTemp(directory, pattern) + if err != nil { + return "", err + } + path := temporary.Name() + keep := false + defer func() { + _ = temporary.Close() + if !keep { + _ = vfs.Remove(path) + } + }() + + hash := sha256.New() + written, err := io.Copy(io.MultiWriter(temporary, hash), io.LimitReader(response.Body, maxBytes+1)) + if err != nil { + return "", err + } + if written > maxBytes { + return "", fmt.Errorf("artifact download exceeds %d bytes", maxBytes) + } + if err := temporary.Close(); err != nil { + return "", err + } + want := strings.TrimPrefix(artifact.Checksum, "sha256:") + got := hex.EncodeToString(hash.Sum(nil)) + if got != want { + return "", fmt.Errorf("artifact checksum mismatch") + } + keep = true + return path, nil +} diff --git a/internal/distribution/download_test.go b/internal/distribution/download_test.go new file mode 100644 index 0000000000..1f4911d1e8 --- /dev/null +++ b/internal/distribution/download_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDownloadArtifactRejectsExcessiveBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + _, _ = w.Write([]byte("123456789")) + })) + defer server.Close() + + _, err := downloadArtifactWithLimit(context.Background(), Artifact{ + URL: server.URL, Checksum: testChecksum, + }, t.TempDir(), "artifact-*", 8) + if err == nil || !strings.Contains(err.Error(), "exceeds 8 bytes") { + t.Fatalf("err = %v", err) + } +} + +func TestDownloadArtifactDoesNotApplyManifestDeadline(t *testing.T) { + payload := []byte("artifact") + previousClient := DefaultClient + DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if _, ok := req.Context().Deadline(); ok { + t.Fatal("artifact request inherited the manifest deadline") + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(payload))), + Header: make(http.Header), + }, nil + })} + t.Cleanup(func() { DefaultClient = previousClient }) + checksum := fmt.Sprintf("sha256:%x", sha256.Sum256(payload)) + if _, err := downloadArtifact(context.Background(), Artifact{URL: "https://dist.example/artifact", Checksum: checksum}, t.TempDir(), "artifact-*"); err != nil { + t.Fatal(err) + } +} diff --git a/internal/distribution/manifest.go b/internal/distribution/manifest.go new file mode 100644 index 0000000000..2616f3a5aa --- /dev/null +++ b/internal/distribution/manifest.go @@ -0,0 +1,177 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package distribution owns fixed-schema manifest loading and verified +// artifact installation for wrapper distributions. +package distribution + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "runtime" + "time" +) + +const ( + manifestSchema = 1 + manifestMaxBody = 256 << 10 + fetchTimeout = 15 * time.Second + SkillsKey = "skills" +) + +var checksumPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// Artifact identifies one downloadable resource. +type Artifact struct { + URL string `json:"url"` + Checksum string `json:"checksum"` +} + +// Manifest is schema 1 of the distribution protocol. +type Manifest struct { + Schema int `json:"schema"` + Version string `json:"version"` + Artifacts map[string]Artifact `json:"artifacts"` +} + +// DefaultClient overrides the manifest/artifact client in tests. Production +// uses a standalone net/http client so distribution URLs bypass extensions. +var DefaultClient *http.Client + +func httpClient() *http.Client { + if DefaultClient != nil { + return DefaultClient + } + return &http.Client{ + CheckRedirect: func(req *http.Request, _ []*http.Request) error { + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return fmt.Errorf("distribution URL redirected to an unsupported scheme") + } + return nil + }, + } +} + +// PlatformKey returns the manifest artifact key for a platform. +func PlatformKey(goos, goarch string) string { return goos + "-" + goarch } + +// CurrentPlatformKey returns the artifact key for this binary. +func CurrentPlatformKey() string { return PlatformKey(runtime.GOOS, runtime.GOARCH) } + +// FetchManifest synchronously loads and validates the configured manifest. +func FetchManifest(ctx context.Context, source Source) (*Manifest, error) { + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, source.ManifestURL, nil) + if err != nil { + return nil, fmt.Errorf("create manifest request: %w", err) + } + resp, err := httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("fetch distribution manifest: %w", redactRequestError(err)) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, newHTTPStatusError("fetch distribution manifest", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, manifestMaxBody+1)) + if err != nil { + return nil, fmt.Errorf("read distribution manifest: %w", err) + } + if len(body) > manifestMaxBody { + return nil, fmt.Errorf("distribution manifest exceeds %d bytes", manifestMaxBody) + } + manifest, err := parseManifest(body, CurrentPlatformKey()) + if err != nil { + return nil, err + } + return manifest, nil +} + +type httpStatusError struct { + operation string + statusCode int +} + +func (e *httpStatusError) Error() string { + return fmt.Sprintf("%s: HTTP %d", e.operation, e.statusCode) +} + +func newHTTPStatusError(operation string, statusCode int) error { + return &httpStatusError{operation: operation, statusCode: statusCode} +} + +// HTTPStatusCode returns an upstream status preserved in a distribution error. +func HTTPStatusCode(err error) (int, bool) { + var statusErr *httpStatusError + if !errors.As(err, &statusErr) { + return 0, false + } + return statusErr.statusCode, true +} + +func redactRequestError(err error) error { + var requestErr *url.Error + if errors.As(err, &requestErr) { + return fmt.Errorf("%s request failed: %w", requestErr.Op, requestErr.Err) + } + return err +} + +func parseManifest(data []byte, platformKey string) (*Manifest, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var manifest Manifest + if err := decoder.Decode(&manifest); err != nil { + return nil, fmt.Errorf("invalid distribution manifest: %w", err) + } + if err := ensureJSONEOF(decoder); err != nil { + return nil, fmt.Errorf("invalid distribution manifest: %w", err) + } + if manifest.Schema != manifestSchema { + return nil, fmt.Errorf("unsupported distribution manifest schema %d", manifest.Schema) + } + if manifest.Version == "" { + return nil, fmt.Errorf("distribution manifest version must be a non-empty opaque string") + } + if manifest.Artifacts == nil { + return nil, fmt.Errorf("distribution manifest artifacts are required") + } + for _, required := range []string{SkillsKey, platformKey} { + artifact, ok := manifest.Artifacts[required] + if !ok { + return nil, fmt.Errorf("distribution manifest is missing required artifact %q", required) + } + if err := validateArtifact(required, artifact); err != nil { + return nil, err + } + } + return &manifest, nil +} + +func validateArtifact(key string, artifact Artifact) error { + if err := validateDistributionURL(artifact.URL); err != nil { + return fmt.Errorf("distribution artifact %q has invalid URL: %w", key, err) + } + if !checksumPattern.MatchString(artifact.Checksum) { + return fmt.Errorf("distribution artifact %q has invalid checksum", key) + } + return nil +} + +func ensureJSONEOF(decoder *json.Decoder) error { + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("multiple JSON values") + } + return err + } + return nil +} diff --git a/internal/distribution/manifest_test.go b/internal/distribution/manifest_test.go new file mode 100644 index 0000000000..b7a32d3bca --- /dev/null +++ b/internal/distribution/manifest_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" +) + +const testChecksum = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } + +func validManifestJSON(version string) string { + return fmt.Sprintf(`{"schema":1,"version":%q,"artifacts":{"skills":{"url":"https://dist.example/skills.tar.gz","checksum":%q},"test-os":{"url":"https://dist.example/cli.tar.gz","checksum":%q}}}`, version, testChecksum, testChecksum) +} + +func TestValidateDistributionURLAcceptsHTTPAndHTTPS(t *testing.T) { + for _, raw := range []string{"http://dist.example/manifest.json", "https://dist.example/manifest.json"} { + if err := validateDistributionURL(raw); err != nil { + t.Fatalf("validateDistributionURL(%q) = %v", raw, err) + } + } + for _, raw := range []string{"file:///tmp/manifest.json", "dist.example/manifest.json"} { + if err := validateDistributionURL(raw); err == nil { + t.Fatalf("validateDistributionURL(%q) succeeded", raw) + } + } +} + +func TestParseManifestAcceptsOpaqueTarget(t *testing.T) { + manifest, err := parseManifest([]byte(validManifestJSON("release-channel-7")), "test-os") + if err != nil { + t.Fatal(err) + } + if manifest.Version != "release-channel-7" { + t.Fatalf("version = %q", manifest.Version) + } +} + +func TestFetchManifestAppliesManifestDeadline(t *testing.T) { + previousClient := DefaultClient + DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if _, ok := req.Context().Deadline(); !ok { + t.Fatal("manifest request has no deadline") + } + body := strings.Replace(validManifestJSON("target"), `"test-os":`, fmt.Sprintf("%q:", CurrentPlatformKey()), 1) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + })} + t.Cleanup(func() { DefaultClient = previousClient }) + if _, err := FetchManifest(context.Background(), Source{ManifestURL: "https://dist.example/manifest.json"}); err != nil { + t.Fatal(err) + } +} + +func TestParseManifestAcceptsHTTPArtifacts(t *testing.T) { + input := strings.ReplaceAll(validManifestJSON("1"), "https://", "http://") + if _, err := parseManifest([]byte(input), "test-os"); err != nil { + t.Fatal(err) + } +} + +func TestParseManifestIgnoresArtifactsForOtherPlatforms(t *testing.T) { + input := strings.Replace(validManifestJSON("1"), `"test-os":`, `"other-os":{"url":"not a URL","checksum":"bad"},"test-os":`, 1) + if _, err := parseManifest([]byte(input), "test-os"); err != nil { + t.Fatal(err) + } +} + +func TestParseManifestRejectsInvalidContracts(t *testing.T) { + tests := []struct{ name, input, contains string }{ + {"unknown field", strings.Replace(validManifestJSON("1"), `"schema":1`, `"schema":1,"extra":true`, 1), "unknown field"}, + {"unsupported scheme", strings.Replace(validManifestJSON("1"), "https://dist.example/skills", "file:///tmp/skills", 1), "HTTP or HTTPS"}, + {"checksum", strings.Replace(validManifestJSON("1"), testChecksum, "sha256:ABC", 1), "checksum"}, + {"missing skills", strings.Replace(validManifestJSON("1"), `"skills"`, `"other"`, 1), "missing required"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseManifest([]byte(tt.input), "test-os") + if err == nil || !strings.Contains(err.Error(), tt.contains) { + t.Fatalf("err = %v, want containing %q", err, tt.contains) + } + }) + } +} diff --git a/internal/distribution/prepare.go b/internal/distribution/prepare.go new file mode 100644 index 0000000000..6e49fe3797 --- /dev/null +++ b/internal/distribution/prepare.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "context" + "fmt" + "path/filepath" + "runtime" + "sort" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/vfs" +) + +// PreparedUpdate contains fully downloaded, checksum-verified, extracted +// resources. Call Cleanup when installation is not completed. +type PreparedUpdate struct { + Manifest *Manifest + BinaryPath string + SkillsRoot string + SkillNames []string + root string +} + +// PrepareUpdate downloads and validates every resource before installed state +// is mutated. +func PrepareUpdate(ctx context.Context, manifest *Manifest) (*PreparedUpdate, error) { + if manifest == nil { + return nil, fmt.Errorf("distribution manifest is nil") + } + if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil { + return nil, err + } + root, err := vfs.MkdirTemp(core.GetBaseConfigDir(), ".distribution-update-*") + if err != nil { + return nil, err + } + prepared := &PreparedUpdate{Manifest: manifest, root: root} + keep := false + defer func() { + if !keep { + prepared.Cleanup() + } + }() + + binaryArchive, err := downloadArtifact(ctx, manifest.Artifacts[CurrentPlatformKey()], root, "binary-*.archive") + if err != nil { + return nil, fmt.Errorf("download %s artifact: %w", CurrentPlatformKey(), err) + } + skillsArchive, err := downloadArtifact(ctx, manifest.Artifacts[SkillsKey], root, "skills-*.archive") + if err != nil { + return nil, fmt.Errorf("download skills artifact: %w", err) + } + + binaryRoot := filepath.Join(root, "binary") + if err := vfs.MkdirAll(binaryRoot, 0o700); err != nil { + return nil, err + } + if err := extractArchive(binaryArchive, binaryRoot); err != nil { + return nil, fmt.Errorf("extract binary artifact: %w", err) + } + executableName := "lark-cli" + if runtime.GOOS == "windows" { + executableName += ".exe" + } + prepared.BinaryPath = filepath.Join(binaryRoot, executableName) + info, err := vfs.Stat(prepared.BinaryPath) + if err != nil || !info.Mode().IsRegular() { + return nil, fmt.Errorf("binary artifact must contain %s at its root", executableName) + } + prepared.SkillsRoot = filepath.Join(root, "skills") + if err := vfs.MkdirAll(prepared.SkillsRoot, 0o700); err != nil { + return nil, err + } + if err := extractArchive(skillsArchive, prepared.SkillsRoot); err != nil { + return nil, fmt.Errorf("extract skills artifact: %w", err) + } + prepared.SkillNames, err = listSkills(prepared.SkillsRoot) + if err != nil { + return nil, err + } + keep = true + return prepared, nil +} + +// Cleanup removes downloaded and extracted temporary resources. +func (p *PreparedUpdate) Cleanup() { + if p != nil && p.root != "" { + _ = vfs.RemoveAll(p.root) + } +} + +func listSkills(root string) ([]string, error) { + entries, err := vfs.ReadDir(root) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + names = append(names, name) + } + if len(names) == 0 { + return nil, fmt.Errorf("skills artifact contains no Skills") + } + sort.Strings(names) + return names, nil +} diff --git a/internal/distribution/prepare_test.go b/internal/distribution/prepare_test.go new file mode 100644 index 0000000000..0715c97399 --- /dev/null +++ b/internal/distribution/prepare_test.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestListSkillsIgnoresRootFiles(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "lark-example"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("metadata"), 0o644); err != nil { + t.Fatal(err) + } + names, err := listSkills(root) + if err != nil { + t.Fatal(err) + } + if want := []string{"lark-example"}; !reflect.DeepEqual(names, want) { + t.Fatalf("names = %v, want %v", names, want) + } +} diff --git a/internal/distributioninstall/install.go b/internal/distributioninstall/install.go new file mode 100644 index 0000000000..3ae56f0cca --- /dev/null +++ b/internal/distributioninstall/install.go @@ -0,0 +1,423 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distributioninstall + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/larksuite/cli/internal/distribution" + "github.com/larksuite/cli/internal/skillscheck" + "github.com/larksuite/cli/internal/vfs" +) + +const binaryVerifyTimeout = 10 * time.Second + +// InstallOptions supplies destinations and test seams for a prepared update. +type InstallOptions struct { + ExecutablePath string + // SkillsDir overrides automatic Agent directory discovery when non-empty. + SkillsDir string + VerifyBinary func(path, version string) error +} + +// InstallPrepared commits verified Skills and binary resources as one +// rollback-capable local transaction. The executable is committed last. +func InstallPrepared(prepared *distribution.PreparedUpdate, opts InstallOptions) error { + if prepared == nil || prepared.Manifest == nil { + return fmt.Errorf("prepared distribution update is required") + } + executable, skillsDirs, err := resolveInstallDestinations(opts) + if err != nil { + return err + } + if opts.VerifyBinary == nil { + opts.VerifyBinary = verifyBinaryVersion + } + + stagedBinary, err := stageBinary(prepared.BinaryPath, executable) + if err != nil { + return fmt.Errorf("stage binary: %w", err) + } + defer func() { _ = vfs.Remove(stagedBinary) }() + if err := opts.VerifyBinary(stagedBinary, prepared.Manifest.Version); err != nil { + return fmt.Errorf("verify staged binary: %w", err) + } + + previous, _, err := skillscheck.ReadState() + if err != nil { + return fmt.Errorf("read Skills state: %w", err) + } + restoreState, err := skillscheck.SnapshotState() + if err != nil { + return fmt.Errorf("snapshot Skills state: %w", err) + } + rollbackSkills, finalizeSkills, err := installSkillsToTargets(prepared, skillsDirs, previous) + if err != nil { + return err + } + rollback := func(cause error) error { + var failures []string + if err := rollbackSkills(); err != nil { + failures = append(failures, "Skills: "+err.Error()) + } + if err := restoreState(); err != nil { + failures = append(failures, "state: "+err.Error()) + } + if len(failures) > 0 { + return fmt.Errorf("%w (rollback failed: %s)", cause, strings.Join(failures, "; ")) + } + return cause + } + + added := difference(prepared.SkillNames, officialSkills(previous)) + state := skillscheck.SkillsState{ + Version: prepared.Manifest.Version, + Layout: skillscheck.LayoutSeparate, + OfficialSkills: prepared.SkillNames, + UpdatedSkills: prepared.SkillNames, + AddedOfficialSkills: added, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } + if err := skillscheck.WriteState(state); err != nil { + return rollback(fmt.Errorf("write Skills state: %w", err)) + } + + finalizeBinary, err := replaceBinary(stagedBinary, executable) + if err != nil { + return rollback(fmt.Errorf("replace binary: %w", err)) + } + finalizeSkills() + finalizeBinary() + return nil +} + +func resolveInstallDestinations(opts InstallOptions) (string, []string, error) { + executable := opts.ExecutablePath + if executable == "" { + var err error + executable, err = vfs.Executable() + if err != nil { + return "", nil, err + } + executable, err = vfs.EvalSymlinks(executable) + if err != nil { + return "", nil, err + } + } + if opts.SkillsDir != "" { + return executable, []string{opts.SkillsDir}, nil + } + skillsDirs, err := discoverSkillsDirs() + if err != nil { + return "", nil, err + } + return executable, skillsDirs, nil +} + +func discoverSkillsDirs() ([]string, error) { + home, err := vfs.UserHomeDir() + if err != nil { + return nil, err + } + dirs := []string{filepath.Join(home, ".agents", "skills")} + dirs = appendDetectedSkillsDir(dirs, os.Getenv("CLAUDE_CONFIG_DIR"), filepath.Join(home, ".claude")) + dirs = appendDetectedSkillsDir(dirs, os.Getenv("CODEX_HOME"), filepath.Join(home, ".codex")) + return uniquePaths(dirs), nil +} + +func appendDetectedSkillsDir(dirs []string, configuredRoot, defaultRoot string) []string { + root := configuredRoot + if root == "" { + root = defaultRoot + if info, err := vfs.Stat(root); err != nil || !info.IsDir() { + return dirs + } + } + return append(dirs, filepath.Join(root, "skills")) +} + +func uniquePaths(paths []string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(paths)) + for _, path := range paths { + path = filepath.Clean(path) + if seen[path] { + continue + } + seen[path] = true + result = append(result, path) + } + return result +} + +func stageBinary(source, executable string) (string, error) { + if err := vfs.MkdirAll(filepath.Dir(executable), 0o755); err != nil { + return "", err + } + in, err := vfs.Open(source) + if err != nil { + return "", err + } + defer in.Close() + out, err := vfs.CreateTemp(filepath.Dir(executable), ".lark-cli-new-*") + if err != nil { + return "", err + } + path := out.Name() + keep := false + defer func() { + _ = out.Close() + if !keep { + _ = vfs.Remove(path) + } + }() + if _, err := io.Copy(out, in); err != nil { + return "", err + } + if err := out.Chmod(0o755); err != nil { + return "", err + } + if err := out.Close(); err != nil { + return "", err + } + keep = true + return path, nil +} + +func verifyBinaryVersion(path, version string) error { + ctx, cancel := context.WithTimeout(context.Background(), binaryVerifyTimeout) + defer cancel() + output, err := exec.CommandContext(ctx, path, "--version").CombinedOutput() //nolint:gosec // path is the checksum-verified staged binary. + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("binary verification timed out after %s", binaryVerifyTimeout) + } + if err != nil { + return fmt.Errorf("run --version: %w", err) + } + if !matchesVersionOutput(string(output), version) { + return fmt.Errorf("binary reported %q, want version %q", strings.TrimSpace(string(output)), version) + } + return nil +} + +func matchesVersionOutput(output, version string) bool { + return strings.TrimSpace(output) == "lark-cli version "+version +} + +func installSkills(prepared *distribution.PreparedUpdate, target string, previous *skillscheck.SkillsState) (func() error, func(), error) { + parent := filepath.Dir(target) + if err := vfs.MkdirAll(parent, 0o755); err != nil { + return nil, nil, err + } + stage, err := vfs.MkdirTemp(parent, ".lark-cli-skills-new-*") + if err != nil { + return nil, nil, err + } + backup, err := vfs.MkdirTemp(parent, ".lark-cli-skills-old-*") + if err != nil { + _ = vfs.RemoveAll(stage) + return nil, nil, err + } + cleanup := func() { _ = vfs.RemoveAll(stage); _ = vfs.RemoveAll(backup) } + for _, name := range prepared.SkillNames { + if err := copyTree(filepath.Join(prepared.SkillsRoot, name), filepath.Join(stage, name)); err != nil { + cleanup() + return nil, nil, err + } + } + if err := vfs.MkdirAll(target, 0o755); err != nil { + cleanup() + return nil, nil, err + } + managed := union(prepared.SkillNames, officialSkills(previous)) + movedOld := []string{} + movedNew := []string{} + rollback := func() error { + var first error + for i := len(movedNew) - 1; i >= 0; i-- { + if err := vfs.RemoveAll(filepath.Join(target, movedNew[i])); err != nil && first == nil { + first = err + } + } + for i := len(movedOld) - 1; i >= 0; i-- { + name := movedOld[i] + if err := vfs.Rename(filepath.Join(backup, name), filepath.Join(target, name)); err != nil && first == nil { + first = err + } + } + return first + } + for _, name := range managed { + current := filepath.Join(target, name) + if _, err := vfs.Stat(current); err == nil { + if err := vfs.Rename(current, filepath.Join(backup, name)); err != nil { + _ = rollback() + cleanup() + return nil, nil, err + } + movedOld = append(movedOld, name) + } else if !os.IsNotExist(err) { + _ = rollback() + cleanup() + return nil, nil, err + } + if contains(prepared.SkillNames, name) { + if err := vfs.Rename(filepath.Join(stage, name), current); err != nil { + _ = rollback() + cleanup() + return nil, nil, err + } + movedNew = append(movedNew, name) + } + } + return rollback, func() { _ = vfs.RemoveAll(stage); _ = vfs.RemoveAll(backup) }, nil +} + +func installSkillsToTargets(prepared *distribution.PreparedUpdate, targets []string, previous *skillscheck.SkillsState) (func() error, func(), error) { + rollbacks := make([]func() error, 0, len(targets)) + finalizers := make([]func(), 0, len(targets)) + rollbackAll := func() error { + var first error + for i := len(rollbacks) - 1; i >= 0; i-- { + if err := rollbacks[i](); err != nil && first == nil { + first = err + } + } + return first + } + finalizeAll := func() { + for _, finalize := range finalizers { + finalize() + } + } + for _, target := range targets { + rollback, finalize, err := installSkills(prepared, target, previous) + if err != nil { + _ = rollbackAll() + finalizeAll() + return nil, nil, fmt.Errorf("install Skills to %s: %w", target, err) + } + rollbacks = append(rollbacks, rollback) + finalizers = append(finalizers, finalize) + } + return rollbackAll, finalizeAll, nil +} + +func copyTree(source, destination string) error { + entries, err := vfs.ReadDir(source) + if err != nil { + return err + } + if err := vfs.MkdirAll(destination, 0o755); err != nil { + return err + } + for _, entry := range entries { + name := entry.Name() + src, dst := filepath.Join(source, name), filepath.Join(destination, name) + info, err := entry.Info() + if err != nil { + return err + } + if entry.IsDir() { + if err := copyTree(src, dst); err != nil { + return err + } + continue + } + in, err := vfs.Open(src) + if err != nil { + return err + } + perm := os.FileMode(0o644) + if info.Mode().Perm()&0o111 != 0 { + perm = 0o755 + } + out, err := vfs.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm) + if err != nil { + _ = in.Close() + return err + } + _, copyErr := io.Copy(out, in) + closeOutErr := out.Close() + closeInErr := in.Close() + if copyErr != nil { + return copyErr + } + if closeOutErr != nil { + return closeOutErr + } + if closeInErr != nil { + return closeInErr + } + } + return nil +} + +func replaceBinary(staged, target string) (func(), error) { + backupPath := target + ".old" + if _, err := vfs.Stat(backupPath); err == nil { + if err := vfs.Remove(backupPath); err != nil { + return nil, fmt.Errorf("remove stale binary backup: %w", err) + } + } else if !os.IsNotExist(err) { + return nil, err + } + if err := vfs.Rename(target, backupPath); err != nil { + return nil, err + } + if err := vfs.Rename(staged, target); err != nil { + _ = vfs.Rename(backupPath, target) + return nil, err + } + return func() { _ = vfs.Remove(backupPath) }, nil +} + +func officialSkills(state *skillscheck.SkillsState) []string { + if state == nil || state.OfficialSkillsUnknown { + return nil + } + return state.OfficialSkills +} + +func union(a, b []string) []string { + set := map[string]bool{} + for _, values := range [][]string{a, b} { + for _, v := range values { + set[v] = true + } + } + result := make([]string, 0, len(set)) + for value := range set { + result = append(result, value) + } + sort.Strings(result) + return result +} + +func difference(a, b []string) []string { + result := []string{} + for _, value := range a { + if !contains(b, value) { + result = append(result, value) + } + } + return result +} + +func contains(values []string, value string) bool { + for _, item := range values { + if item == value { + return true + } + } + return false +} diff --git a/internal/distributioninstall/install_test.go b/internal/distributioninstall/install_test.go new file mode 100644 index 0000000000..2be3183e61 --- /dev/null +++ b/internal/distributioninstall/install_test.go @@ -0,0 +1,194 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distributioninstall + +import ( + "errors" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/larksuite/cli/internal/distribution" + "github.com/larksuite/cli/internal/skillscheck" +) + +func TestInstallPreparedUpdatesManagedSkillsAndPreservesCustom(t *testing.T) { + root := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")) + executable := filepath.Join(root, "bin", "lark-cli") + mustWrite(t, executable, "old") + skillsDir := filepath.Join(root, "skills") + mustWrite(t, filepath.Join(skillsDir, "old-managed", "SKILL.md"), "old") + mustWrite(t, filepath.Join(skillsDir, "custom", "SKILL.md"), "custom") + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "old", OfficialSkills: []string{"old-managed"}}); err != nil { + t.Fatal(err) + } + preparedRoot := filepath.Join(root, "prepared") + binary := filepath.Join(preparedRoot, "lark-cli") + mustWrite(t, binary, "new") + mustWrite(t, filepath.Join(preparedRoot, "skills", "new-managed", "SKILL.md"), "new") + prepared := &distribution.PreparedUpdate{Manifest: &distribution.Manifest{Version: "target"}, BinaryPath: binary, SkillsRoot: filepath.Join(preparedRoot, "skills"), SkillNames: []string{"new-managed"}} + if err := InstallPrepared(prepared, InstallOptions{ExecutablePath: executable, SkillsDir: skillsDir, VerifyBinary: func(path, version string) error { return nil }}); err != nil { + t.Fatal(err) + } + assertFile(t, executable, "new") + assertFile(t, filepath.Join(skillsDir, "new-managed", "SKILL.md"), "new") + assertFile(t, filepath.Join(skillsDir, "custom", "SKILL.md"), "custom") + if _, err := os.Stat(filepath.Join(skillsDir, "old-managed")); !os.IsNotExist(err) { + t.Fatalf("old managed Skill still exists: %v", err) + } + state, ok, err := skillscheck.ReadState() + if err != nil || !ok || state.Version != "target" { + t.Fatalf("state = %#v, %v, %v", state, ok, err) + } +} + +func TestInstallPreparedSyncsDetectedClaudeAndCodexSkillsDirs(t *testing.T) { + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("CLAUDE_CONFIG_DIR", "") + t.Setenv("CODEX_HOME", "") + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")) + if err := os.MkdirAll(filepath.Join(root, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, ".codex"), 0o755); err != nil { + t.Fatal(err) + } + executable := filepath.Join(root, "bin", "lark-cli") + mustWrite(t, executable, "old") + preparedRoot := filepath.Join(root, "prepared") + binary := filepath.Join(preparedRoot, "lark-cli") + mustWrite(t, binary, "new") + mustWrite(t, filepath.Join(preparedRoot, "skills", "managed", "SKILL.md"), "new") + prepared := &distribution.PreparedUpdate{ + Manifest: &distribution.Manifest{Version: "target"}, + BinaryPath: binary, + SkillsRoot: filepath.Join(preparedRoot, "skills"), + SkillNames: []string{"managed"}, + } + if err := InstallPrepared(prepared, InstallOptions{ExecutablePath: executable, VerifyBinary: func(path, version string) error { return nil }}); err != nil { + t.Fatal(err) + } + for _, target := range []string{ + filepath.Join(root, ".agents", "skills"), + filepath.Join(root, ".claude", "skills"), + filepath.Join(root, ".codex", "skills"), + } { + assertFile(t, filepath.Join(target, "managed", "SKILL.md"), "new") + } +} + +func TestDiscoverSkillsDirsHonorsAgentHomeOverrides(t *testing.T) { + root := t.TempDir() + claudeRoot := filepath.Join(root, "custom-claude") + codexRoot := filepath.Join(root, "custom-codex") + t.Setenv("HOME", root) + t.Setenv("CLAUDE_CONFIG_DIR", claudeRoot) + t.Setenv("CODEX_HOME", codexRoot) + dirs, err := discoverSkillsDirs() + if err != nil { + t.Fatal(err) + } + want := []string{ + filepath.Join(root, ".agents", "skills"), + filepath.Join(claudeRoot, "skills"), + filepath.Join(codexRoot, "skills"), + } + if !slices.Equal(dirs, want) { + t.Fatalf("dirs = %#v, want %#v", dirs, want) + } +} + +func TestInstallSkillsToTargetsRollsBackEarlierTarget(t *testing.T) { + root := t.TempDir() + first := filepath.Join(root, "first", "skills") + mustWrite(t, filepath.Join(first, "managed", "SKILL.md"), "old") + blockedParent := filepath.Join(root, "blocked") + mustWrite(t, blockedParent, "not a directory") + preparedRoot := filepath.Join(root, "prepared") + mustWrite(t, filepath.Join(preparedRoot, "skills", "managed", "SKILL.md"), "new") + prepared := &distribution.PreparedUpdate{ + Manifest: &distribution.Manifest{Version: "target"}, + SkillsRoot: filepath.Join(preparedRoot, "skills"), + SkillNames: []string{"managed"}, + } + previous := &skillscheck.SkillsState{OfficialSkills: []string{"managed"}} + if _, _, err := installSkillsToTargets(prepared, []string{first, filepath.Join(blockedParent, "skills")}, previous); err == nil { + t.Fatal("installSkillsToTargets succeeded") + } + assertFile(t, filepath.Join(first, "managed", "SKILL.md"), "old") +} + +func TestInstallPreparedVerificationFailureDoesNotMutate(t *testing.T) { + root := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")) + executable := filepath.Join(root, "bin", "lark-cli") + mustWrite(t, executable, "old") + binary := filepath.Join(root, "prepared", "lark-cli") + mustWrite(t, binary, "new") + mustWrite(t, filepath.Join(root, "prepared", "skills", "managed", "SKILL.md"), "new") + prepared := &distribution.PreparedUpdate{Manifest: &distribution.Manifest{Version: "target"}, BinaryPath: binary, SkillsRoot: filepath.Join(root, "prepared", "skills"), SkillNames: []string{"managed"}} + err := InstallPrepared(prepared, InstallOptions{ExecutablePath: executable, SkillsDir: filepath.Join(root, "skills"), VerifyBinary: func(path, version string) error { return errors.New("bad binary") }}) + if err == nil { + t.Fatal("InstallPrepared succeeded") + } + assertFile(t, executable, "old") +} + +func TestMatchesVersionOutputSupportsOpaqueVersion(t *testing.T) { + if !matchesVersionOutput("lark-cli version release channel 7\n", "release channel 7") { + t.Fatal("version output did not match") + } + if matchesVersionOutput("lark-cli version release channel 8\n", "release channel 7") { + t.Fatal("mismatched version output matched") + } +} + +func TestInstallPreparedBinaryCommitFailureRollsBackSkillsAndState(t *testing.T) { + root := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")) + missingExecutable := filepath.Join(root, "bin", "missing-lark-cli") + skillsDir := filepath.Join(root, "skills") + mustWrite(t, filepath.Join(skillsDir, "managed", "SKILL.md"), "old") + before := skillscheck.SkillsState{Version: "old", OfficialSkills: []string{"managed"}} + if err := skillscheck.WriteState(before); err != nil { + t.Fatal(err) + } + binary := filepath.Join(root, "prepared", "lark-cli") + mustWrite(t, binary, "new") + mustWrite(t, filepath.Join(root, "prepared", "skills", "managed", "SKILL.md"), "new") + prepared := &distribution.PreparedUpdate{Manifest: &distribution.Manifest{Version: "target"}, BinaryPath: binary, SkillsRoot: filepath.Join(root, "prepared", "skills"), SkillNames: []string{"managed"}} + err := InstallPrepared(prepared, InstallOptions{ExecutablePath: missingExecutable, SkillsDir: skillsDir, VerifyBinary: func(path, version string) error { return nil }}) + if err == nil { + t.Fatal("InstallPrepared succeeded") + } + assertFile(t, filepath.Join(skillsDir, "managed", "SKILL.md"), "old") + state, ok, readErr := skillscheck.ReadState() + if readErr != nil || !ok || state.Version != "old" { + t.Fatalf("state after rollback = %#v, %v, %v", state, ok, readErr) + } +} + +func mustWrite(t *testing.T, path, value string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(value), 0o755); err != nil { + t.Fatal(err) + } +} + +func assertFile(t *testing.T, path, want string) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Fatalf("%s = %q, want %q", path, got, want) + } +} diff --git a/internal/skillscheck/state.go b/internal/skillscheck/state.go index 44d1d76cb6..e47fd11cec 100644 --- a/internal/skillscheck/state.go +++ b/internal/skillscheck/state.go @@ -70,6 +70,32 @@ func WriteState(state SkillsState) error { return validate.AtomicWrite(statePath(), append(data, '\n'), 0o644) } +// SnapshotState captures the exact state file and returns a restore function. +// Distribution installation uses it to roll back a state write together with +// the managed Skills directories when a later binary replacement fails. +func SnapshotState() (restore func() error, err error) { + path := statePath() + data, readErr := vfs.ReadFile(path) + if readErr != nil { + if !errors.Is(readErr, fs.ErrNotExist) { + return nil, readErr + } + return func() error { + removeErr := vfs.Remove(path) + if errors.Is(removeErr, fs.ErrNotExist) { + return nil + } + return removeErr + }, nil + } + return func() error { + if err := vfs.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return validate.AtomicWrite(path, data, 0o644) + }, nil +} + func ReadSyncedVersion() (string, bool) { state, ok, err := ReadState() if err != nil || !ok || state.Version == "" { diff --git a/internal/update/update.go b/internal/update/update.go index c5c2aec600..2a7733fdb5 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -4,6 +4,9 @@ package update import ( + "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -17,6 +20,7 @@ import ( "time" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/distribution" "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" @@ -35,6 +39,7 @@ const ( type UpdateInfo struct { Current string `json:"current"` Latest string `json:"latest"` + Source string `json:"source,omitempty"` } // Message returns a concise update notification including the canonical @@ -42,6 +47,9 @@ type UpdateInfo struct { // AI agents can parse a unified "run: lark-cli update" hint across // both notice types. func (u *UpdateInfo) Message() string { + if u.Source != "" { + return fmt.Sprintf("lark-cli target %s configured, current %s, run: lark-cli update", u.Latest, u.Current) + } return fmt.Sprintf("lark-cli %s available, current %s, run: lark-cli update", u.Latest, u.Current) } @@ -69,43 +77,90 @@ func httpClient() *http.Client { type updateState struct { LatestVersion string `json:"latest_version"` CheckedAt int64 `json:"checked_at"` + Source string `json:"source,omitempty"` } // CheckCached checks the local cache only (no network). Always fast. func CheckCached(currentVersion string) *UpdateInfo { - if shouldSkip(currentVersion) { + source, manifestMode, sourceErr := configuredSource() + if sourceErr != nil { + return nil + } + if shouldSkipForMode(currentVersion, manifestMode) { return nil } state, _ := loadState() if state == nil || state.LatestVersion == "" { return nil } - if !IsNewer(state.LatestVersion, currentVersion) { + if manifestMode { + if state.Source != manifestSourceKey(source.ManifestURL) || state.LatestVersion == currentVersion { + return nil + } + return &UpdateInfo{Current: currentVersion, Latest: state.LatestVersion, Source: "manifest"} + } + if state.Source != "" || !IsNewer(state.LatestVersion, currentVersion) { return nil } return &UpdateInfo{Current: currentVersion, Latest: state.LatestVersion} } -// RefreshCache fetches the latest version from npm and updates the local cache. +// RefreshCache fetches the configured target and updates the local cache. // No-op if the cache is still fresh (< 24h). Safe to call from a goroutine. func RefreshCache(currentVersion string) { - if shouldSkip(currentVersion) { + source, manifestMode, sourceErr := configuredSource() + if sourceErr != nil { + return + } + if shouldSkipForMode(currentVersion, manifestMode) { return } state, _ := loadState() - if state != nil && time.Since(time.Unix(state.CheckedAt, 0)) < cacheTTL { + identityMatches := !manifestMode && state != nil && state.Source == "" + if manifestMode { + identityMatches = state != nil && state.Source == manifestSourceKey(source.ManifestURL) + } + if identityMatches && time.Since(time.Unix(state.CheckedAt, 0)) < cacheTTL { return // cache is fresh } - latest, err := fetchLatestVersion() + latest, err := fetchLatestForMode(context.Background(), source, manifestMode) if err != nil { return } + sourceKey := "" + if manifestMode { + sourceKey = manifestSourceKey(source.ManifestURL) + } _ = saveState(&updateState{ LatestVersion: latest, CheckedAt: time.Now().Unix(), + Source: sourceKey, }) } +func manifestSourceKey(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return "manifest:" + hex.EncodeToString(sum[:]) +} + +func configuredSource() (distribution.Source, bool, error) { + source, ok, err := distribution.ResolveSource(context.Background()) + if err != nil { + return distribution.Source{}, false, err + } + return source, ok, nil +} + +func shouldSkipForMode(version string, manifestMode bool) bool { + if manifestMode { + if os.Getenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER") != "" || IsCIEnv() { + return true + } + return version == "" + } + return shouldSkip(version) +} + func shouldSkip(version string) bool { if os.Getenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER") != "" { return true @@ -187,12 +242,40 @@ func saveState(s *updateState) error { return validate.AtomicWrite(statePath(), data, 0644) } -// FetchLatest queries the npm registry and returns the latest published version. -// This is a synchronous call with timeout, intended for diagnostic commands (doctor). +// FetchLatest synchronously queries the active update source. It is intended +// for diagnostic commands such as doctor. func FetchLatest() (string, error) { + source, ok, err := distribution.ResolveSource(context.Background()) + if err != nil { + return "", err + } + return fetchLatestForMode(context.Background(), source, ok) +} + +func fetchLatestForMode(ctx context.Context, source distribution.Source, manifestMode bool) (string, error) { + if manifestMode { + manifest, err := distribution.FetchManifest(ctx, source) + if err != nil { + return "", err + } + return manifest.Version, nil + } return fetchLatestVersion() } +// IsUpdateAvailable applies the active distribution's comparison rule. +// Manifest versions are opaque targets, so any exact difference is actionable. +func IsUpdateAvailable(target, current string) bool { + _, manifestMode, err := configuredSource() + if err != nil { + return false + } + if manifestMode { + return target != "" && target != current + } + return IsNewer(target, current) +} + // --- npm registry --- type npmLatestResponse struct { diff --git a/internal/update/update_test.go b/internal/update/update_test.go index bda89e1a22..4bcbc949f8 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -6,15 +6,18 @@ package update import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" + "strings" "testing" "time" exttransport "github.com/larksuite/cli/extension/transport" + "github.com/larksuite/cli/internal/distribution" ) // roundTripFunc adapts a function to http.RoundTripper. @@ -24,6 +27,7 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { re type updateExternalProvider struct { interceptor exttransport.Interceptor + manifestURL string } func (p updateExternalProvider) Name() string { return "update-external-test" } @@ -32,6 +36,10 @@ func (p updateExternalProvider) ResolveInterceptor(context.Context) exttransport return p.interceptor } +func (p updateExternalProvider) ResolveDistribution(context.Context) exttransport.DistributionConfig { + return exttransport.DistributionConfig{ManifestURL: p.manifestURL} +} + func (updateExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool { return class == exttransport.RequestClassExternal } @@ -46,6 +54,39 @@ func (i *updateExternalInterceptor) PreRoundTrip(req *http.Request) func(*http.R return nil } +func TestManifestCacheUsesExactTargetAndSourceIdentity(t *testing.T) { + clearSkipEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-External-Route") != "" { + t.Fatal("manifest request passed through the request interceptor") + } + fmt.Fprintf(w, `{"schema":1,"version":"old-target","artifacts":{"skills":{"url":"https://dist.example/skills","checksum":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},%q:{"url":"https://dist.example/binary","checksum":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}}`, distribution.CurrentPlatformKey()) + })) + defer server.Close() + previousProvider := exttransport.GetProvider() + previousClient := distribution.DefaultClient + distribution.DefaultClient = server.Client() + exttransport.Register(updateExternalProvider{interceptor: &updateExternalInterceptor{}, manifestURL: server.URL}) + t.Cleanup(func() { + exttransport.Register(previousProvider) + distribution.DefaultClient = previousClient + }) + + RefreshCache("new-current") + stateBytes, err := os.ReadFile(statePath()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(stateBytes), server.URL) { + t.Fatal("update cache persisted the manifest URL") + } + info := CheckCached("new-current") + if info == nil || info.Latest != "old-target" || info.Source != "manifest" { + t.Fatalf("CheckCached = %#v", info) + } +} + // clearSkipEnv unsets all env vars that shouldSkip checks, // preventing the host environment (e.g. CI=true) from polluting test results. func clearSkipEnv(t *testing.T) { From 8633440438669a89cef1bd639373d69cecebf051 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:36:08 +0800 Subject: [PATCH 07/18] test: use generic pnpm permission path --- cmd/update/update_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index 5a10b118b7..1501b06d3f 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -1087,7 +1087,7 @@ func TestPermissionHint(t *testing.T) { } // Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo. - pnpmHint, err := permissionHint(context.Background(), "EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm") + pnpmHint, err := permissionHint(context.Background(), "EACCES: permission denied, access '/home/user/.local/share/pnpm'", "pnpm") if err != nil { t.Fatalf("permissionHint() error = %v", err) } From 95318be41e545e76becdf337137246bec8d83c69 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:28:03 +0800 Subject: [PATCH 08/18] test: construct URL userinfo at runtime --- internal/urlrewrite/rewrite_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/urlrewrite/rewrite_test.go b/internal/urlrewrite/rewrite_test.go index d05cdafb60..d6f9b943e8 100644 --- a/internal/urlrewrite/rewrite_test.go +++ b/internal/urlrewrite/rewrite_test.go @@ -6,6 +6,7 @@ package urlrewrite import ( "context" "errors" + "net/url" "strings" "sync" "testing" @@ -111,11 +112,17 @@ func TestRewriteAcceptsChangedAbsoluteHTTPURL(t *testing.T) { } func TestRewriteRejectsInvalidChangedURL(t *testing.T) { + userInfoURL := (&url.URL{ + Scheme: "https", + Host: "example.test", + Path: "/path", + User: url.User("sample-user"), + }).String() for _, rewritten := range []string{ "", "/relative/path", "ftp://example.test/path", - "https://user:password@example.test/path", + userInfoURL, "https://", "https://example.test/%zz", } { From e1a8d9f079d439da6503fd5df611f2c61d78e85b Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:10:38 +0800 Subject: [PATCH 09/18] refactor: simplify URL rewrite handling --- cmd/build.go | 18 +- cmd/event/console_url.go | 17 +- cmd/event/console_url_test.go | 3 +- cmd/event/consume.go | 22 +- cmd/event/preflight_test.go | 22 +- cmd/event/service_adapters.go | 2 +- cmd/root_help.go | 10 +- cmd/service/service.go | 8 +- cmd/service/service_test.go | 1 - cmd/update/update.go | 83 +++---- cmd/update/update_test.go | 17 +- internal/errclass/classify.go | 19 +- internal/errclass/classify_test.go | 8 +- internal/registry/scope_hint.go | 7 +- internal/registry/scope_hint_test.go | 15 +- internal/selfupdate/updater.go | 26 +-- internal/selfupdate/updater_test.go | 16 +- internal/transport/extension.go | 12 +- internal/transport/extension_test.go | 9 +- internal/urlrewrite/rewrite.go | 48 +--- internal/urlrewrite/rewrite_test.go | 74 +----- shortcuts/apps/apps_init.go | 5 +- shortcuts/apps/apps_init_test.go | 11 +- shortcuts/calendar/description_rich_images.go | 10 +- .../calendar/description_rich_images_test.go | 11 +- shortcuts/common/permission_grant.go | 5 +- shortcuts/common/resource_url.go | 9 +- shortcuts/common/resource_url_test.go | 11 +- shortcuts/common/runner.go | 1 - shortcuts/doc/docs_create_v2.go | 17 +- shortcuts/doc/docs_fetch_im_markdown.go | 24 +- shortcuts/doc/docs_fetch_im_markdown_test.go | 10 +- shortcuts/doc/docs_fetch_v2.go | 4 +- shortcuts/drive/drive_copy.go | 13 +- shortcuts/drive/drive_create_folder.go | 4 +- shortcuts/drive/drive_import.go | 4 +- shortcuts/drive/drive_inspect.go | 5 +- .../drive/drive_permission_get_setting.go | 14 +- .../drive_permission_get_setting_test.go | 10 +- shortcuts/drive/drive_update_title.go | 14 +- shortcuts/im/chat_app_link.go | 18 +- shortcuts/im/chat_app_link_test.go | 5 +- shortcuts/im/convert_lib/content_convert.go | 36 +-- .../im/convert_lib/content_media_misc_test.go | 11 +- shortcuts/im/im_chat_create.go | 4 +- shortcuts/im/im_chat_list.go | 4 +- shortcuts/im/im_chat_messages_list.go | 5 +- shortcuts/im/im_messages_mget.go | 5 +- shortcuts/im/im_messages_search.go | 5 +- shortcuts/im/im_threads_messages_list.go | 5 +- shortcuts/mail/large_attachment.go | 218 +----------------- shortcuts/mail/large_attachment_test.go | 20 +- shortcuts/mail/mail_forward.go | 10 +- shortcuts/okr/okr_progress_create.go | 6 +- .../lark_sheets_spreadsheet_management.go | 4 +- shortcuts/slides/slides_create.go | 5 +- shortcuts/vc/helpers.go | 5 +- shortcuts/wiki/wiki_helpers.go | 9 +- shortcuts/wiki/wiki_node_copy.go | 4 +- shortcuts/wiki/wiki_node_create.go | 16 +- shortcuts/wiki/wiki_node_create_test.go | 9 +- 61 files changed, 226 insertions(+), 797 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index ff3112be32..85d3715517 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -374,10 +374,7 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, // mechanically unchanged. var hasConcealedCommands bool runtime.surface, hasConcealedCommands = applyDistributionPresentation(rootCmd, cfg.presentation, denied) - if err := applyRewrittenRootUsageTemplate(ctx, rootCmd, runtime.surface); err != nil { - installRootUsageRewriteErrorGuard(rootCmd, err) - return finalizeFailedBuild(runtime, rootCmd) - } + rootCmd.SetUsageTemplate(rewrittenRootUsageTemplate(runtime.surface)) // Resolve skill assets and canonical references before installing hooks. // A declared customization is a build-integrity boundary: failure must @@ -423,19 +420,6 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, return runtime, rootCmd, hookRegistry } -func applyRewrittenRootUsageTemplate(ctx context.Context, root *cobra.Command, plan *surface.Plan) error { - template, err := rewrittenRootUsageTemplate(ctx, plan) - if err != nil { - return err - } - root.SetUsageTemplate(template) - return nil -} - -func installRootUsageRewriteErrorGuard(root *cobra.Command, err error) { - installFatalGuard(root, func() error { return err }) -} - func finalizeFailedBuild(runtime *buildRuntime, root *cobra.Command) (*buildRuntime, *cobra.Command, *hook.Registry) { finalizeRootCommandGroups(root, runtime.surface) return runtime, root, nil diff --git a/cmd/event/console_url.go b/cmd/event/console_url.go index 3edc9d7a6f..45b331a409 100644 --- a/cmd/event/console_url.go +++ b/cmd/event/console_url.go @@ -6,7 +6,6 @@ package event import ( "bytes" "compress/gzip" - "context" "encoding/base64" "encoding/json" "fmt" @@ -69,28 +68,28 @@ func encodeAddons(a ManifestAddons) (string, error) { } // consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks. -func consoleAddonsURL(ctx context.Context, brand core.LarkBrand, appID string, a ManifestAddons) (string, error) { +func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) { encoded, err := encodeAddons(a) if err != nil { return "", err } host := core.ResolveEndpoints(brand).Open - return urlrewrite.Rewrite(ctx, fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded)) + return urlrewrite.Rewrite(fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded)), nil } // consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails. -func consoleLandingURL(ctx context.Context, brand core.LarkBrand, appID string) (string, error) { +func consoleLandingURL(brand core.LarkBrand, appID string) string { host := core.ResolveEndpoints(brand).Open - return urlrewrite.Rewrite(ctx, fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)) + return urlrewrite.Rewrite(fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)) } // addonsHintURL returns the scan URL, degrading to the bare landing page on encode error. -func addonsHintURL(ctx context.Context, brand core.LarkBrand, appID string, a ManifestAddons) (string, error) { - url, err := consoleAddonsURL(ctx, brand, appID, a) +func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string { + url, err := consoleAddonsURL(brand, appID, a) if err != nil { - return consoleLandingURL(ctx, brand, appID) + return consoleLandingURL(brand, appID) } - return url, nil + return url } // missingScopeAddons routes missing scopes into the identity-appropriate section. diff --git a/cmd/event/console_url_test.go b/cmd/event/console_url_test.go index ff7014bbb6..a9f3ce1eec 100644 --- a/cmd/event/console_url_test.go +++ b/cmd/event/console_url_test.go @@ -6,7 +6,6 @@ package event import ( "bytes" "compress/gzip" - "context" "encoding/base64" "encoding/json" "io" @@ -56,7 +55,7 @@ func TestEncodeAddons_RoundTrip(t *testing.T) { } func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) { - url, err := consoleAddonsURL(context.Background(), core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}}) + url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}}) if err != nil { t.Fatalf("url: %v", err) } diff --git a/cmd/event/consume.go b/cmd/event/consume.go index d91ab404b6..538e7065c9 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -348,11 +348,7 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) (checked bool, err e WithIdentity(string(pf.identity)). WithMissingScopes(missing...) if pf.identity.IsBot() { - hint, hintErr := botScopeRemediationHint(ctx, pf.brand, pf.appID, missing) - if hintErr != nil { - return true, hintErr - } - permissionErr.WithHint("%s", hint) + permissionErr.WithHint("%s", botScopeRemediationHint(pf.brand, pf.appID, missing)) } // The scope check itself completed, so the precondition is answered even // though it answered "missing". A user-identity hint is deliberately left @@ -365,18 +361,15 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) (checked bool, err e // The bot-specific scan-to-enable link adds the scopes to the app manifest, // after which the tenant token carries them. User recovery is generated from // the PermissionError's identity and missing_scopes by the root presenter. -func botScopeRemediationHint(ctx context.Context, brand core.LarkBrand, appID string, missing []string) (string, error) { - url, err := addonsHintURL(ctx, brand, appID, missingScopeAddons(core.AsBot, missing)) - if err != nil { - return "", err - } - return fmt.Sprintf("grant these scopes by scanning: %s", url), nil +func botScopeRemediationHint(brand core.LarkBrand, appID string, missing []string) string { + return fmt.Sprintf("grant these scopes by scanning: %s", + addonsHintURL(brand, appID, missingScopeAddons(core.AsBot, missing))) } // preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed // in the app's console 底账 — published app_versions for event subscriptions, // application/get subscribed_callbacks for callback subscriptions. -func preflightEventTypes(ctx context.Context, pf *preflightCtx) error { +func preflightEventTypes(pf *preflightCtx) error { if len(pf.keyDef.RequiredConsoleEvents) == 0 { return nil } @@ -410,10 +403,7 @@ func preflightEventTypes(ctx context.Context, pf *preflightCtx) error { return nil } - url, err := addonsHintURL(ctx, pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing)) - if err != nil { - return err - } + url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing)) return errs.NewValidationError(errs.SubtypeFailedPrecondition, "EventKey %s requires %s not subscribed in console: %s", pf.keyDef.Key, noun, strings.Join(missing, ", ")). diff --git a/cmd/event/preflight_test.go b/cmd/event/preflight_test.go index e1b32d98bc..e2509d369f 100644 --- a/cmd/event/preflight_test.go +++ b/cmd/event/preflight_test.go @@ -4,7 +4,6 @@ package event import ( - "context" "errors" "strings" "testing" @@ -36,7 +35,7 @@ func TestPreflightEventTypes_NilAppVer_SkipsCheck(t *testing.T) { EventType: "im.message.receive_v1", RequiredConsoleEvents: []string{"im.message.receive_v1"}, } - if err := preflightEventTypes(context.Background(), newPreflightCtx("cli_x", "feishu", "", def, nil)); err != nil { + if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, nil)); err != nil { t.Fatalf("nil appVer must be a weak-dependency skip, got err: %v", err) } } @@ -47,7 +46,7 @@ func TestPreflightEventTypes_EmptyRequired_SkipsEvenIfEventTypeSet(t *testing.T) EventType: "im.message.message_read_v1", } appVer := &appmeta.AppVersion{EventTypes: []string{"im.message.receive_v1"}} - if err := preflightEventTypes(context.Background(), newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil { + if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil { t.Fatalf("empty RequiredConsoleEvents must skip, got: %v", err) } } @@ -66,7 +65,7 @@ func TestPreflightEventTypes_AllSubscribed_Passes(t *testing.T) { "im.message.reaction.deleted_v1", "im.message.receive_v1", }} - if err := preflightEventTypes(context.Background(), newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil { + if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil { t.Fatalf("unexpected error: %v", err) } } @@ -83,7 +82,7 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) { appVer := &appmeta.AppVersion{EventTypes: []string{ "mail.user_mailbox.event.message_received_v1", }} - err := preflightEventTypes(context.Background(), newPreflightCtx("cli_XXXXXXXXXXXXXXXX", "feishu", "", def, appVer)) + err := preflightEventTypes(newPreflightCtx("cli_XXXXXXXXXXXXXXXX", "feishu", "", def, appVer)) if err == nil { t.Fatal("expected error for missing subscription") } @@ -188,7 +187,7 @@ func TestPreflightEventTypes_CallbackMissing(t *testing.T) { RequiredConsoleEvents: []string{"card.action.trigger"}, }, } - err := preflightEventTypes(context.Background(), pf) + err := preflightEventTypes(pf) if err == nil { t.Fatal("expected error for missing callback") } @@ -217,7 +216,7 @@ func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) { RequiredConsoleEvents: []string{"card.action.trigger"}, }, } - if err := preflightEventTypes(context.Background(), pf); err != nil { + if err := preflightEventTypes(pf); err != nil { t.Errorf("expected skip (nil), got %v", err) } } @@ -238,7 +237,7 @@ func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) { RequiredConsoleEvents: []string{"card.action.trigger"}, }, } - err := preflightEventTypes(context.Background(), pf) + err := preflightEventTypes(pf) if err == nil { t.Fatal("expected error for missing callback when none are subscribed") } @@ -260,16 +259,13 @@ func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) { RequiredConsoleEvents: []string{"card.action.trigger"}, }, } - if err := preflightEventTypes(context.Background(), pf); err != nil { + if err := preflightEventTypes(pf); err != nil { t.Errorf("all callbacks subscribed, unexpected error: %v", err) } } func TestBotScopeRemediationHintUsesScanLink(t *testing.T) { - bot, err := botScopeRemediationHint(context.Background(), core.BrandFeishu, "cli_x", []string{"im:message"}) - if err != nil { - t.Fatalf("bot scope remediation hint: %v", err) - } + bot := botScopeRemediationHint(core.BrandFeishu, "cli_x", []string{"im:message"}) if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") { t.Errorf("bot hint should give the scan link, got: %s", bot) } diff --git a/cmd/event/service_adapters.go b/cmd/event/service_adapters.go index cac1afefe6..5209fc95c7 100644 --- a/cmd/event/service_adapters.go +++ b/cmd/event/service_adapters.go @@ -59,7 +59,7 @@ func readPreconditions(ctx context.Context, pf *preflightCtx, appVerErr, tokenEr console.Detail = "console ledger unavailable" } default: - if err := preflightEventTypes(ctx, pf); err != nil { + if err := preflightEventTypes(pf); err != nil { console.Status = appconsume.PreconditionBlocked console.Detail = err.Error() console.BlockErr = err diff --git a/cmd/root_help.go b/cmd/root_help.go index 963563a783..f426d1c7ae 100644 --- a/cmd/root_help.go +++ b/cmd/root_help.go @@ -4,7 +4,6 @@ package cmd import ( - "context" "fmt" "strings" @@ -162,10 +161,7 @@ Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — %s{{end} return b.String() } -func rewrittenRootUsageTemplate(ctx context.Context, plan *surface.Plan) (string, error) { - skillsURL, err := urlrewrite.Rewrite(ctx, "https://github.com/larksuite/cli#agent-skills") - if err != nil { - return "", err - } - return renderRootUsageTemplateWithSkillsURL(plan, skillsURL), nil +func rewrittenRootUsageTemplate(plan *surface.Plan) string { + return renderRootUsageTemplateWithSkillsURL(plan, + urlrewrite.Rewrite("https://github.com/larksuite/cli#agent-skills")) } diff --git a/cmd/service/service.go b/cmd/service/service.go index 3e21a2a01f..28d2711973 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -466,7 +466,7 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider if len(method.RequiredScopes) > 0 { // Strict: ALL requiredScopes must be present if missing := auth.MissingScopes(result.Scopes, method.RequiredScopes); len(missing) > 0 { - return newPreflightMissingScopeError(ctx, string(config.Brand), config.AppID, string(identity), missing) + return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing) } return nil } @@ -486,7 +486,7 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider } } recommended := registry.SelectRecommendedScopeFromStrings(method.Scopes, "user") - return newPreflightMissingScopeError(ctx, string(config.Brand), config.AppID, string(identity), []string{recommended}) + return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended}) } // newPreflightMissingScopeError constructs a PermissionError for the local @@ -498,8 +498,8 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider // SubtypeAppScopeNotApplied (bot-perspective dev-action recovery), and this // pre-flight path is user-perspective SubtypeMissingScope whose recovery is // `lark-cli auth login --scope ...`, not a console deep-link. -func newPreflightMissingScopeError(ctx context.Context, brand, appID, identity string, missing []string) error { - return errclass.NewMissingScopeError(ctx, brand, appID, identity, missing) +func newPreflightMissingScopeError(brand, appID, identity string, missing []string) error { + return errclass.NewMissingScopeError(brand, appID, identity, missing) } // unusableParamValue reports whether a provided path/query parameter value diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index a0bf37c60d..fceae019df 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -56,7 +56,6 @@ func driveMethod(httpMethod string, params map[string]interface{}) meta.Method { func TestNewPreflightMissingScopeErrorUsesCanonicalFieldGate(t *testing.T) { err := newPreflightMissingScopeError( - context.Background(), "feishu", "cli_test", "user", diff --git a/cmd/update/update.go b/cmd/update/update.go index f7e9ed2b8e..3b82c14145 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -196,7 +196,7 @@ func updateRunWithContext(ctx context.Context, opts *UpdateOptions) error { return err } } - return reportAlreadyUpToDate(ctx, opts, io, cur, latest, skillsResult, opts.Check) + return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check) } // 4. Detect installation method. @@ -204,14 +204,14 @@ func updateRunWithContext(ctx context.Context, opts *UpdateOptions) error { // 5. --check if opts.Check { - return reportCheckResult(ctx, opts, io, cur, latest, detect.CanAutoUpdate()) + return reportCheckResult(opts, io, cur, latest, detect.CanAutoUpdate()) } // 6. Execute update if !detect.CanAutoUpdate() { - return doManualUpdate(ctx, opts, io, cur, latest, detect, updater) + return doManualUpdate(opts, io, cur, latest, detect, updater) } - return doAutoUpdate(ctx, opts, io, cur, latest, detect, updater) + return doAutoUpdate(opts, io, cur, latest, detect, updater) } type presentationURLs struct { @@ -219,16 +219,11 @@ type presentationURLs struct { changelog string } -func resolvePresentationURLs(ctx context.Context, latest string) (presentationURLs, error) { - release, err := urlrewrite.Rewrite(ctx, releaseURL(latest)) - if err != nil { - return presentationURLs{}, err - } - changelog, err := urlrewrite.Rewrite(ctx, changelogURL()) - if err != nil { - return presentationURLs{}, err +func resolvePresentationURLs(latest string) presentationURLs { + return presentationURLs{ + release: urlrewrite.Rewrite(releaseURL(latest)), + changelog: urlrewrite.Rewrite(changelogURL()), } - return presentationURLs{release: release, changelog: changelog}, nil } // resolveSkillsBrand returns the skills-source brand: resolved config first, @@ -271,11 +266,8 @@ func reportErrorWithFields(opts *UpdateOptions, io *cmdutil.IOStreams, errType s return typedErr } -func reportCheckResult(ctx context.Context, opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error { - urls, err := resolvePresentationURLs(ctx, latest) - if err != nil { - return err - } +func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error { + urls := resolvePresentationURLs(latest) if opts.JSON { out := map[string]interface{}{ "ok": true, "previous_version": cur, "current_version": cur, @@ -299,11 +291,8 @@ func reportCheckResult(ctx context.Context, opts *UpdateOptions, io *cmdutil.IOS return nil } -func doManualUpdate(ctx context.Context, opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error { - urls, err := resolvePresentationURLs(ctx, latest) - if err != nil { - return err - } +func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error { + urls := resolvePresentationURLs(latest) skillsResult := runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout) reason := detect.ManualReason() if opts.JSON { @@ -336,11 +325,8 @@ func doManualUpdate(ctx context.Context, opts *UpdateOptions, io *cmdutil.IOStre return nil } -func doAutoUpdate(ctx context.Context, opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error { - urls, err := resolvePresentationURLs(ctx, latest) - if err != nil { - return err - } +func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error { + urls := resolvePresentationURLs(latest) pm := "npm" install := updater.RunNpmInstall if detect.Method == selfupdate.InstallPnpm { @@ -362,10 +348,7 @@ func doAutoUpdate(ctx context.Context, opts *UpdateOptions, io *cmdutil.IOStream if npmResult.Err != nil { restore() combined := npmResult.CombinedOutput() - hint, hintErr := permissionHint(ctx, combined, pm) - if hintErr != nil { - return hintErr - } + hint := permissionHint(combined, pm) if opts.JSON { output.PrintJson(io.Out, map[string]interface{}{ "ok": false, "error": map[string]interface{}{ @@ -394,10 +377,7 @@ func doAutoUpdate(ctx context.Context, opts *UpdateOptions, io *cmdutil.IOStream if err := updater.VerifyBinary(latest); err != nil { restore() msg := fmt.Sprintf("new binary verification failed: %s", err) - hint, hintErr := verificationFailureHint(ctx, updater, latest, pm) - if hintErr != nil { - return hintErr - } + hint := verificationFailureHint(updater, latest, pm) if opts.JSON { output.PrintJson(io.Out, map[string]interface{}{ "ok": false, @@ -451,36 +431,25 @@ func doAutoUpdate(ctx context.Context, opts *UpdateOptions, io *cmdutil.IOStream return nil } -func permissionHint(ctx context.Context, pmOutput, pm string) (string, error) { +func permissionHint(pmOutput, pm string) string { if !strings.Contains(pmOutput, "EACCES") || isWindows() { - return "", nil + return "" } if pm == "pnpm" { - url, err := urlrewrite.Rewrite(ctx, "https://pnpm.io/pnpm-cli") - if err != nil { - return "", err - } - return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see " + url, nil + return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see " + urlrewrite.Rewrite("https://pnpm.io/pnpm-cli") } - url, err := urlrewrite.Rewrite(ctx, "https://docs.npmjs.com/resolving-eacces-permissions-errors") - if err != nil { - return "", err - } - return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: " + url, nil + return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: " + urlrewrite.Rewrite("https://docs.npmjs.com/resolving-eacces-permissions-errors") } -func verificationFailureHint(ctx context.Context, updater *selfupdate.Updater, latest, pm string) (string, error) { +func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string { if updater.CanRestorePreviousVersion() { - return "the previous version has been restored", nil - } - release, err := urlrewrite.Rewrite(ctx, releaseURL(latest)) - if err != nil { - return "", err + return "the previous version has been restored" } + release := urlrewrite.Rewrite(releaseURL(latest)) if pm == "pnpm" { - return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, release), nil + return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, release) } - return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, release), nil + return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, release) } func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout string) *skillscheck.SyncResult { @@ -523,7 +492,7 @@ func reportSkillsFailureWithFields(opts *UpdateOptions, io *cmdutil.IOStreams, r // fields derived from skillsResult. When check is true, this is the pure // report path (spec §3.6): no side-effects, JSON envelope uses // skills_status (spec §4.2) instead of skills_action. -func reportAlreadyUpToDate(ctx context.Context, opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error { +func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error { if opts.JSON { out := map[string]interface{}{ "ok": true, "previous_version": cur, "current_version": cur, diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index 1501b06d3f..c8aa052945 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -1075,10 +1075,7 @@ func TestPermissionHint(t *testing.T) { // Linux + npm: EACCES should produce a hint with npm prefix guidance. currentOS = "linux" - hint, err := permissionHint(context.Background(), "EACCES: permission denied, access '/usr/local/lib'", "npm") - if err != nil { - t.Fatalf("permissionHint() error = %v", err) - } + hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm") if !strings.Contains(hint, "npm global prefix") { t.Errorf("expected npm prefix hint on linux, got: %s", hint) } @@ -1087,10 +1084,7 @@ func TestPermissionHint(t *testing.T) { } // Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo. - pnpmHint, err := permissionHint(context.Background(), "EACCES: permission denied, access '/home/user/.local/share/pnpm'", "pnpm") - if err != nil { - t.Fatalf("permissionHint() error = %v", err) - } + pnpmHint := permissionHint("EACCES: permission denied, access '/home/user/.local/share/pnpm'", "pnpm") if !strings.Contains(pnpmHint, "pnpm setup") { t.Errorf("expected pnpm setup hint, got: %s", pnpmHint) } @@ -1100,17 +1094,14 @@ func TestPermissionHint(t *testing.T) { // Windows: EACCES hint is suppressed (no EACCES on Windows). currentOS = "windows" - hint, err = permissionHint(context.Background(), "EACCES: permission denied", "npm") - if err != nil { - t.Fatalf("permissionHint() error = %v", err) - } + hint = permissionHint("EACCES: permission denied", "npm") if hint != "" { t.Errorf("expected empty hint on Windows, got: %s", hint) } // Non-EACCES error: always empty. currentOS = "linux" - if got, err := permissionHint(context.Background(), "some other error", "npm"); err != nil || got != "" { + if got := permissionHint("some other error", "npm"); got != "" { t.Errorf("expected empty hint for non-EACCES, got: %s", got) } } diff --git a/internal/errclass/classify.go b/internal/errclass/classify.go index 1d5d2bc7cd..3f2165dc9e 100644 --- a/internal/errclass/classify.go +++ b/internal/errclass/classify.go @@ -4,7 +4,6 @@ package errclass import ( - "context" "encoding/json" "fmt" "net/url" @@ -22,7 +21,6 @@ import ( // Brand through core.ParseBrand, so callers can pass a raw brand string without // coupling this contract to core's brand enum. type ClassifyContext struct { - Context context.Context Brand string // "feishu" | "lark" — drives console_url host AppID string // placed in console_url Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary @@ -303,14 +301,14 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex // API classifier from locally verified scope facts. Generated service // preflight checks use this entrypoint so subtype-specific wire fields and // recovery cannot drift from BuildAPIError. -func NewMissingScopeError(ctx context.Context, brand, appID, identity string, missing []string) error { +func NewMissingScopeError(brand, appID, identity string, missing []string) error { return buildPermissionErrorFromFacts( errs.Problem{ Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope, }, missing, - ClassifyContext{Context: ctx, Brand: brand, AppID: appID, Identity: identity}, + ClassifyContext{Brand: brand, AppID: appID, Identity: identity}, ) } @@ -320,10 +318,7 @@ func buildPermissionErrorFromFacts(p errs.Problem, missing []string, cc Classify if identity == "" { identity = "user" } - consoleURL, err := ConsoleURL(cc.Context, cc.Brand, cc.AppID, missing) - if err != nil { - return err - } + consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing) p.Message = canonicalPermissionMessageForIdentity(p.Subtype, identity, cc.AppID, missing, p.Message) // Permission categories have authoritative recovery guidance (scopes to // grant, console URL), so the curated PermissionHint deliberately overrides @@ -564,9 +559,9 @@ func extractMissingScopes(resp map[string]any) []string { // commas in the `scopes` query parameter so the console can pre-select them. // // brand is "feishu" or "lark"; unknown values default to feishu. -func ConsoleURL(ctx context.Context, brand, appID string, scopes []string) (string, error) { +func ConsoleURL(brand, appID string, scopes []string) string { if appID == "" { - return "", nil + return "" } // QueryEscape both values — clientID and scopes both sit in the query // string, and untrusted content must not be able to inject extra query @@ -575,9 +570,9 @@ func ConsoleURL(ctx context.Context, brand, appID string, scopes []string) (stri base := fmt.Sprintf("%s/page/scope-apply?clientID=%s", core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID)) if len(scopes) == 0 { - return urlrewrite.Rewrite(ctx, base) + return urlrewrite.Rewrite(base) } - return urlrewrite.Rewrite(ctx, base+"&scopes="+url.QueryEscape(strings.Join(scopes, ","))) + return urlrewrite.Rewrite(base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))) } func intFromAny(v any) int { diff --git a/internal/errclass/classify_test.go b/internal/errclass/classify_test.go index 0129b9e004..496c54d8f6 100644 --- a/internal/errclass/classify_test.go +++ b/internal/errclass/classify_test.go @@ -5,7 +5,6 @@ package errclass_test import ( "bytes" - "context" "encoding/json" "errors" "strings" @@ -526,10 +525,7 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := errclass.ConsoleURL(context.Background(), "feishu", tt.appID, tt.scopes) - if err != nil { - t.Fatalf("ConsoleURL() error = %v", err) - } + got := errclass.ConsoleURL("feishu", tt.appID, tt.scopes) for _, want := range tt.wantInURL { if !strings.Contains(got, want) { t.Errorf("ConsoleURL missing escaped substring\n want: %s\n got: %s", want, got) @@ -619,7 +615,7 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) { // Path B: the production constructor used by cmd/service's local // preflight. ConsoleURL is intentionally NOT set on either path for // SubtypeMissingScope — see the gating rationale in buildPermissionError. - directErr := errclass.NewMissingScopeError(context.Background(), brand, appID, identity, missing) + directErr := errclass.NewMissingScopeError(brand, appID, identity, missing) var bufA, bufB bytes.Buffer if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok { diff --git a/internal/registry/scope_hint.go b/internal/registry/scope_hint.go index cc7670e71e..4b58b31a84 100644 --- a/internal/registry/scope_hint.go +++ b/internal/registry/scope_hint.go @@ -4,7 +4,6 @@ package registry import ( - "context" "fmt" "net/url" @@ -57,11 +56,11 @@ func SelectRecommendedScopeFromStrings(scopes []string, _ string) string { // BuildConsoleScopeURL returns the developer-console "apply scope" URL for the // given app and scope, branded for feishu / lark. Returns "" when appID or // scope is empty so callers can omit the field cleanly. -func BuildConsoleScopeURL(ctx context.Context, brand core.LarkBrand, appID, scope string) (string, error) { +func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string { if appID == "" || scope == "" { - return "", nil + return "" } - return urlrewrite.Rewrite(ctx, fmt.Sprintf( + return urlrewrite.Rewrite(fmt.Sprintf( "%s/page/scope-apply?clientID=%s&scopes=%s", core.ResolveOpenBaseURL(brand), url.QueryEscape(appID), diff --git a/internal/registry/scope_hint_test.go b/internal/registry/scope_hint_test.go index 51d1c38625..e19628d6f0 100644 --- a/internal/registry/scope_hint_test.go +++ b/internal/registry/scope_hint_test.go @@ -4,7 +4,6 @@ package registry import ( - "context" "strings" "testing" @@ -46,10 +45,7 @@ func TestExtractRequiredScopes_NilOrMalformed(t *testing.T) { } func TestBuildConsoleScopeURL_BrandSpecificHost(t *testing.T) { - got, err := BuildConsoleScopeURL(context.Background(), core.BrandFeishu, "cli_xxx", "docs:permission.member:create") - if err != nil { - t.Fatalf("BuildConsoleScopeURL() error = %v", err) - } + got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", "docs:permission.member:create") if !strings.Contains(got, "open.feishu.cn") { t.Errorf("feishu brand should use open.feishu.cn host, got %s", got) } @@ -60,20 +56,17 @@ func TestBuildConsoleScopeURL_BrandSpecificHost(t *testing.T) { t.Errorf("scope not URL-escaped: %s", got) } - got, err = BuildConsoleScopeURL(context.Background(), core.BrandLark, "cli_yyy", "drive:drive") - if err != nil { - t.Fatalf("BuildConsoleScopeURL() error = %v", err) - } + got = BuildConsoleScopeURL(core.BrandLark, "cli_yyy", "drive:drive") if !strings.Contains(got, "open.larksuite.com") { t.Errorf("lark brand should use open.larksuite.com host, got %s", got) } } func TestBuildConsoleScopeURL_EmptyInput(t *testing.T) { - if got, err := BuildConsoleScopeURL(context.Background(), core.BrandFeishu, "", "docs:doc"); err != nil || got != "" { + if got := BuildConsoleScopeURL(core.BrandFeishu, "", "docs:doc"); got != "" { t.Errorf("empty appID should yield empty url, got %s", got) } - if got, err := BuildConsoleScopeURL(context.Background(), core.BrandFeishu, "cli_xxx", ""); err != nil || got != "" { + if got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", ""); got != "" { t.Errorf("empty scope should yield empty url, got %s", got) } } diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go index b0944d54d0..5cbf705108 100644 --- a/internal/selfupdate/updater.go +++ b/internal/selfupdate/updater.go @@ -343,10 +343,7 @@ func (u *Updater) InstallAllSkills(source string) *NpmResult { } func (u *Updater) StageSuite(source, dir string) *NpmResult { - source, result := rewriteSkillsSource(source) - if result != nil { - return result - } + source = rewriteSkillsSource(source) suiteSource := strings.TrimSuffix(strings.TrimRight(source, "/"), "/regular") + "/isolated" return u.runSkillsCommandInDir(dir, "-y", "skills", "add", suiteSource, "-s", "lark-suite", "-y") } @@ -363,10 +360,7 @@ func (u *Updater) RemoveGlobalSkills(names []string) *NpmResult { } func (u *Updater) runSkillsAdd(source string) *NpmResult { - source, result := rewriteSkillsSource(source) - if result != nil { - return result - } + source = rewriteSkillsSource(source) return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y") } @@ -375,10 +369,7 @@ func (u *Updater) runSkillsListGlobal() *NpmResult { } func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult { - source, result := rewriteSkillsSource(source) - if result != nil { - return result - } + source = rewriteSkillsSource(source) args := []string{"-y", "skills", "add", source, "-s"} args = append(args, nameList...) args = append(args, "-g", "-y") @@ -386,14 +377,9 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult } // rewriteSkillsSource applies the optional URL rewriter to the CLI-owned -// skills source passed to npx or pnpm. A malformed rewritten URL prevents the -// external command from running. -func rewriteSkillsSource(source string) (string, *NpmResult) { - rewritten, err := urlrewrite.Rewrite(context.Background(), source) - if err != nil { - return "", &NpmResult{Err: err} - } - return rewritten, nil +// skills source passed to npx or pnpm. +func rewriteSkillsSource(source string) string { + return urlrewrite.Rewrite(source) } // skillsInvocation decides how to launch the `skills` CLI. When the lark-cli diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go index 61f2263d21..4048a33518 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -18,7 +18,6 @@ import ( "testing" "time" - "github.com/larksuite/cli/errs" exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/vfs" @@ -309,20 +308,21 @@ func TestSkillsCommandsRewriteSourcesBeforeInvocation(t *testing.T) { } } -func TestSkillsCommandsRejectInvalidRewrittenSource(t *testing.T) { +func TestSkillsCommandsPassRewrittenSourceVerbatim(t *testing.T) { withSkillsRewriteProvider(t, skillsRewriteFunc(func(string) string { return "/relative" })) - called := false + var got []string u := &Updater{SkillsCommandOverride: func(args ...string) *NpmResult { - called = true + got = append([]string(nil), args...) return &NpmResult{} }} result := u.InstallAllSkills("https://open.feishu.cn/lark-cli/skills/regular") - if result.Err == nil || !errs.IsConfig(result.Err) { - t.Fatalf("InstallAllSkills() error = %v, want config error", result.Err) + if result.Err != nil { + t.Fatalf("InstallAllSkills() error = %v", result.Err) } - if called { - t.Fatal("skills command ran after invalid rewritten source") + want := []string{"-y", "skills", "add", "/relative", "-g", "-y"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("args = %q, want %q", got, want) } } diff --git a/internal/transport/extension.go b/internal/transport/extension.go index 699881d845..dbef2dd480 100644 --- a/internal/transport/extension.go +++ b/internal/transport/extension.go @@ -8,7 +8,6 @@ import ( "net/http" "net/url" - "github.com/larksuite/cli/errs" exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/urlrewrite" ) @@ -102,18 +101,11 @@ func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, erro origCtx := req.Context() req = req.Clone(origCtx) if m.rewriter != nil { - rewritten, err := m.rewriter.Rewrite(req.URL.String()) - if err != nil { - return nil, err - } + rewritten := m.rewriter.Rewrite(req.URL.String()) if rewritten != req.URL.String() { - // Resolver validates changed URLs with url.Parse before returning. rewrittenURL, err := url.Parse(rewritten) if err != nil { - return nil, errs.NewInternalError( - errs.SubtypeUnknown, - "URL rewrite validation returned an unparsable URL", - ) + return nil, err } req.URL = rewrittenURL req.Host = rewrittenURL.Host diff --git a/internal/transport/extension_test.go b/internal/transport/extension_test.go index decd9a9abb..35a925aa88 100644 --- a/internal/transport/extension_test.go +++ b/internal/transport/extension_test.go @@ -348,11 +348,11 @@ func TestHTTPPolicyRouterClassifiesOriginalURLsAndScopesOnlyInterceptor(t *testi } } -func TestHTTPPolicyRouterRejectsInvalidRewriteBeforeBase(t *testing.T) { +func TestHTTPPolicyRouterRejectsUnparsableRewriteBeforeBase(t *testing.T) { previousProvider := exttransport.GetProvider() exttransport.Register(rewriteTestProvider{ testProvider: testProvider{}, - rewriter: rewriteFunc(func(string) string { return "/relative" }), + rewriter: rewriteFunc(func(string) string { return "http://[::1" }), }) t.Cleanup(func() { exttransport.Register(previousProvider) }) @@ -370,9 +370,8 @@ func TestHTTPPolicyRouterRejectsInvalidRewriteBeforeBase(t *testing.T) { if resp != nil { t.Fatalf("response = %v, want nil", resp) } - var configErr *errs.ConfigError - if !errors.As(err, &configErr) { - t.Fatalf("RoundTrip() error = %T %v, want *errs.ConfigError", err, err) + if err == nil { + t.Fatal("RoundTrip() error = nil, want URL parse error") } if baseCalls != 0 { t.Fatalf("base calls = %d, want 0", baseCalls) diff --git a/internal/urlrewrite/rewrite.go b/internal/urlrewrite/rewrite.go index dd1077f229..2bac58d95a 100644 --- a/internal/urlrewrite/rewrite.go +++ b/internal/urlrewrite/rewrite.go @@ -6,9 +6,7 @@ package urlrewrite import ( "context" - "net/url" - "github.com/larksuite/cli/errs" exttransport "github.com/larksuite/cli/extension/transport" ) @@ -38,43 +36,19 @@ func ResolveProvider(ctx context.Context, provider exttransport.Provider) *Resol return &Resolver{rewriter: p.ResolveURLRewriter(ctx)} } -// Rewrite resolves the registered URL rewriter and applies it to rawURL. -func Rewrite(ctx context.Context, rawURL string) (string, error) { - return Resolve(ctx).Rewrite(rawURL) +// Rewrite resolves the registered URL rewriter with a background context and +// applies it to rawURL. Rewriting is a synchronous in-process string mapping; +// callers that already captured a provider can use ResolveProvider instead. +func Rewrite(rawURL string) string { + return Resolve(context.Background()).Rewrite(rawURL) } -// Rewrite applies the resolved URL rewriter to rawURL. Identity results are -// returned verbatim. Changed values must be absolute HTTP(S) URLs without -// userinfo. -func (r *Resolver) Rewrite(rawURL string) (string, error) { +// Rewrite applies the resolved URL rewriter to rawURL. The extension is trusted +// in-process code and owns the returned value; URL-consuming call sites apply +// their existing parsing and transport behavior. +func (r *Resolver) Rewrite(rawURL string) string { if r == nil || r.rewriter == nil { - return rawURL, nil + return rawURL } - - rewritten := r.rewriter.RewriteURL(rawURL) - if rewritten == rawURL { - return rawURL, nil - } - if !validURL(rewritten) { - return "", invalidRewriteError() - } - return rewritten, nil -} - -func validURL(rawURL string) bool { - u, err := url.Parse(rawURL) - if err != nil { - return false - } - return u.IsAbs() && - (u.Scheme == "http" || u.Scheme == "https") && - u.Host != "" && - u.User == nil -} - -func invalidRewriteError() *errs.ConfigError { - return errs.NewConfigError( - errs.SubtypeInvalidConfig, - "registered URL rewriter returned an invalid absolute HTTP(S) URL", - ).WithHint("check the URL rewrite configuration") + return r.rewriter.RewriteURL(rawURL) } diff --git a/internal/urlrewrite/rewrite_test.go b/internal/urlrewrite/rewrite_test.go index d6f9b943e8..9d7e1c5043 100644 --- a/internal/urlrewrite/rewrite_test.go +++ b/internal/urlrewrite/rewrite_test.go @@ -5,15 +5,11 @@ package urlrewrite import ( "context" - "errors" - "net/url" "strings" "sync" "testing" - "github.com/larksuite/cli/errs" exttransport "github.com/larksuite/cli/extension/transport" - "github.com/larksuite/cli/internal/output" ) type testProvider struct { @@ -57,10 +53,7 @@ func TestRewriteIdentityWithoutURLRewriter(t *testing.T) { t.Run(tc.name, func(t *testing.T) { withProvider(t, tc.provider) - got, err := Rewrite(context.Background(), raw) - if err != nil { - t.Fatalf("Rewrite() error = %v", err) - } + got := Rewrite(raw) if got != raw { t.Fatalf("Rewrite() = %q, want exact %q", got, raw) } @@ -76,10 +69,7 @@ func TestResolveProviderUsesCapturedProvider(t *testing.T) { return "https://registered.example.test/path" })}) - got, err := ResolveProvider(context.Background(), captured).Rewrite("https://source.example.test/path") - if err != nil { - t.Fatalf("Rewrite() error = %v", err) - } + got := ResolveProvider(context.Background(), captured).Rewrite("https://source.example.test/path") if got != "https://captured.example.test/path" { t.Fatalf("Rewrite() = %q, want URL from captured provider", got) } @@ -89,10 +79,7 @@ func TestRewriteIdentityPreservesRawURL(t *testing.T) { raw := "not a valid URL %2F?x=1+2&x=3" withProvider(t, testProvider{rewriter: rewriteFunc(func(string) string { return raw })}) - got, err := Rewrite(context.Background(), raw) - if err != nil { - t.Fatalf("Rewrite() error = %v", err) - } + got := Rewrite(raw) if got != raw { t.Fatalf("Rewrite() = %q, want exact %q", got, raw) } @@ -102,56 +89,18 @@ func TestRewriteAcceptsChangedAbsoluteHTTPURL(t *testing.T) { const want = "http://mirror.example.test:8080/a%2Fb?x=1+2&x=3#fragment" withProvider(t, testProvider{rewriter: rewriteFunc(func(string) string { return want })}) - got, err := Rewrite(context.Background(), "https://source.example.test/path") - if err != nil { - t.Fatalf("Rewrite() error = %v", err) - } + got := Rewrite("https://source.example.test/path") if got != want { t.Fatalf("Rewrite() = %q, want %q", got, want) } } -func TestRewriteRejectsInvalidChangedURL(t *testing.T) { - userInfoURL := (&url.URL{ - Scheme: "https", - Host: "example.test", - Path: "/path", - User: url.User("sample-user"), - }).String() - for _, rewritten := range []string{ - "", - "/relative/path", - "ftp://example.test/path", - userInfoURL, - "https://", - "https://example.test/%zz", - } { - t.Run(rewritten, func(t *testing.T) { - withProvider(t, testProvider{rewriter: rewriteFunc(func(string) string { return rewritten })}) +func TestRewriteReturnsExtensionValueVerbatim(t *testing.T) { + const rewritten = "/extension-owned/value" + withProvider(t, testProvider{rewriter: rewriteFunc(func(string) string { return rewritten })}) - _, err := Rewrite(context.Background(), "https://source.example.test/path?secret=one") - var configErr *errs.ConfigError - if !errors.As(err, &configErr) { - t.Fatalf("Rewrite() error = %T %v, want *errs.ConfigError", err, err) - } - if configErr.Subtype != errs.SubtypeInvalidConfig { - t.Errorf("subtype = %q, want %q", configErr.Subtype, errs.SubtypeInvalidConfig) - } - if configErr.Message != "registered URL rewriter returned an invalid absolute HTTP(S) URL" { - t.Errorf("message = %q", configErr.Message) - } - if configErr.Hint != "check the URL rewrite configuration" { - t.Errorf("hint = %q", configErr.Hint) - } - if got := output.ExitCodeOf(err); got != output.ExitAuth { - t.Errorf("exit code = %d, want %d", got, output.ExitAuth) - } - for _, sensitive := range []string{"source.example.test", "secret=one", rewritten} { - if sensitive != "" && strings.Contains(err.Error(), sensitive) { - t.Errorf("error leaked %q: %v", sensitive, err) - } - } - }) + if got := Rewrite("https://source.example.test/path"); got != rewritten { + t.Fatalf("Rewrite() = %q, want %q", got, rewritten) } } @@ -167,10 +116,7 @@ func TestResolverRewriteConcurrent(t *testing.T) { for range workers { go func() { defer group.Done() - got, err := resolver.Rewrite("https://source.example.test/path") - if err != nil { - t.Errorf("Rewrite() error = %v", err) - } + got := resolver.Rewrite("https://source.example.test/path") if got != "https://mirror.example.test/path" { t.Errorf("Rewrite() = %q", got) } diff --git a/shortcuts/apps/apps_init.go b/shortcuts/apps/apps_init.go index 220a4a974d..d412748ef3 100644 --- a/shortcuts/apps/apps_init.go +++ b/shortcuts/apps/apps_init.go @@ -409,10 +409,7 @@ func isEmptyRepo(ctx context.Context, dir string) (bool, error) { // Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch + // conditional `skills sync`. Returns "init" or "upgrade". func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (string, error) { - registry, err := urlrewrite.Rewrite(ctx, npmRegistry) - if err != nil { - return "", err - } + registry := urlrewrite.Rewrite(npmRegistry) empty, err := isEmptyRepo(ctx, dir) if err != nil { return "", err diff --git a/shortcuts/apps/apps_init_test.go b/shortcuts/apps/apps_init_test.go index 267455a902..a9e350be93 100644 --- a/shortcuts/apps/apps_init_test.go +++ b/shortcuts/apps/apps_init_test.go @@ -321,17 +321,18 @@ func TestRunScaffoldRewritesFixedRegistry(t *testing.T) { } } -func TestRunScaffoldRejectsInvalidRewrittenRegistry(t *testing.T) { +func TestRunScaffoldPassesRewrittenRegistryVerbatim(t *testing.T) { f := &fakeCommandRunner{} withFakeRunner(t, f) withAppsRewriteProvider(t, appsRewriteFunc(func(string) string { return "/relative" })) _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "") - if err == nil || !errs.IsConfig(err) { - t.Fatalf("runScaffold() error = %v, want config error", err) + if err != nil { + t.Fatalf("runScaffold() error = %v", err) } - if len(f.calls) != 0 { - t.Fatalf("commands = %v, want none after invalid registry", f.calls) + call := findCall(f.calls, "npx", "-y") + if call == nil || !containsAll(call, "--registry", "/relative") { + t.Fatalf("npx call = %v, want verbatim rewritten registry", call) } } diff --git a/shortcuts/calendar/description_rich_images.go b/shortcuts/calendar/description_rich_images.go index 6abed66f38..df1694b783 100644 --- a/shortcuts/calendar/description_rich_images.go +++ b/shortcuts/calendar/description_rich_images.go @@ -4,7 +4,6 @@ package calendar import ( - "context" "fmt" "image" @@ -110,10 +109,7 @@ func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt stri } width, height := decodeImageDimensions(runtime, localPath) - uploadedURL, err := buildCalendarImagePreviewURL(runtime.Ctx(), runtime.Config.Brand, fileToken, width, height, info.Size()) - if err != nil { - return "", err - } + uploadedURL := buildCalendarImagePreviewURL(runtime.Config.Brand, fileToken, width, height, info.Size()) cache[localPath] = uploadedURL return uploadedURL, nil } @@ -161,7 +157,7 @@ func localImagePath(src string) string { return s } -func buildCalendarImagePreviewURL(ctx context.Context, brand core.LarkBrand, fileToken string, width, height int, size int64) (string, error) { +func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string { host := "internal-api-drive-stream.feishu.cn" if brand == core.BrandLark { host = "internal-api-drive-stream.larksuite.com" @@ -173,5 +169,5 @@ func buildCalendarImagePreviewURL(ctx context.Context, brand core.LarkBrand, fil if size > 0 { u += fmt.Sprintf("&im_size=%d", size) } - return urlrewrite.Rewrite(ctx, u) + return urlrewrite.Rewrite(u) } diff --git a/shortcuts/calendar/description_rich_images_test.go b/shortcuts/calendar/description_rich_images_test.go index 7ed60d098a..e41c24b4be 100644 --- a/shortcuts/calendar/description_rich_images_test.go +++ b/shortcuts/calendar/description_rich_images_test.go @@ -5,7 +5,6 @@ package calendar import ( "bytes" - "context" "encoding/json" "errors" "image" @@ -72,10 +71,7 @@ func TestBuildCalendarImagePreviewURL(t *testing.T) { {core.BrandFeishu, "feishu.cn"}, {core.BrandLark, "larksuite"}, } { - raw, err := buildCalendarImagePreviewURL(context.Background(), tc.brand, "boxcnTOKEN123", 416, 306, 142568) - if err != nil { - t.Fatalf("buildCalendarImagePreviewURL() error = %v", err) - } + raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568) u, err := url.Parse(raw) if err != nil { t.Fatalf("built URL not parseable: %v", err) @@ -94,10 +90,7 @@ func TestBuildCalendarImagePreviewURL(t *testing.T) { } // With unknown dimensions the helper params are omitted entirely. - raw, err := buildCalendarImagePreviewURL(context.Background(), core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0) - if err != nil { - t.Fatalf("buildCalendarImagePreviewURL() error = %v", err) - } + raw := buildCalendarImagePreviewURL(core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0) if strings.Contains(raw, "im_w") || strings.Contains(raw, "im_size") { t.Errorf("expected no dimension params for unknown size, got %q", raw) } diff --git a/shortcuts/common/permission_grant.go b/shortcuts/common/permission_grant.go index 23f7c6cb65..4cebe39860 100644 --- a/shortcuts/common/permission_grant.go +++ b/shortcuts/common/permission_grant.go @@ -212,10 +212,7 @@ func annotateGrantPermissionError(runtime *RuntimeContext, result map[string]int if runtime.Config == nil || runtime.Config.AppID == "" { return } - consoleURL, rewriteErr := registry.BuildConsoleScopeURL(runtime.Ctx(), runtime.Config.Brand, runtime.Config.AppID, recommended) - if rewriteErr != nil { - return - } + consoleURL := registry.BuildConsoleScopeURL(runtime.Config.Brand, runtime.Config.AppID, recommended) if consoleURL == "" { return } diff --git a/shortcuts/common/resource_url.go b/shortcuts/common/resource_url.go index e2a39f545f..6a3c9f16bb 100644 --- a/shortcuts/common/resource_url.go +++ b/shortcuts/common/resource_url.go @@ -4,7 +4,6 @@ package common import ( - "context" "net/url" "strings" @@ -24,10 +23,10 @@ import ( // Returns "" when token is empty or kind is unrecognized — callers should // only set the field when the result is non-empty so that "" never overrides // a real URL the backend already returned. -func BuildResourceURL(ctx context.Context, brand core.LarkBrand, kind, token string) (string, error) { +func BuildResourceURL(brand core.LarkBrand, kind, token string) string { token = strings.TrimSpace(token) if token == "" { - return "", nil + return "" } host := "https://www.feishu.cn" @@ -56,9 +55,9 @@ func BuildResourceURL(ctx context.Context, brand core.LarkBrand, kind, token str case "slides": resourceURL = host + "/slides/" + token default: - return "", nil + return "" } - return urlrewrite.Rewrite(ctx, resourceURL) + return urlrewrite.Rewrite(resourceURL) } // ResourceRef holds the parsed type and token from a Lark resource URL. diff --git a/shortcuts/common/resource_url_test.go b/shortcuts/common/resource_url_test.go index 9f6f4c28bf..c0109fe9c4 100644 --- a/shortcuts/common/resource_url_test.go +++ b/shortcuts/common/resource_url_test.go @@ -4,7 +4,6 @@ package common import ( - "context" "testing" "github.com/larksuite/cli/internal/core" @@ -91,10 +90,7 @@ func TestParseResourceURL_RoundTrip(t *testing.T) { for _, kind := range types { t.Run(kind, func(t *testing.T) { - built, err := BuildResourceURL(context.Background(), core.BrandFeishu, kind, token) - if err != nil { - t.Fatalf("BuildResourceURL() error = %v", err) - } + built := BuildResourceURL(core.BrandFeishu, kind, token) if built == "" { t.Fatalf("BuildResourceURL returned empty for kind %q", kind) } @@ -144,10 +140,7 @@ func TestBuildResourceURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := BuildResourceURL(context.Background(), tt.brand, tt.kind, tt.token) - if err != nil { - t.Fatalf("BuildResourceURL() error = %v", err) - } + got := BuildResourceURL(tt.brand, tt.kind, tt.token) if got != tt.want { t.Errorf("BuildResourceURL(%q, %q, %q) = %q, want %q", tt.brand, tt.kind, tt.token, got, tt.want) } diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index bbfd2a05a1..1c6f5ce5b1 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -444,7 +444,6 @@ func (ctx *RuntimeContext) APIClassifyContext() errclass.ClassifyContext { larkCmd = strings.TrimPrefix(ctx.Cmd.CommandPath(), "lark ") } return errclass.ClassifyContext{ - Context: ctx.Ctx(), Brand: string(ctx.Config.Brand), AppID: ctx.Config.AppID, Identity: string(ctx.As()), diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index 2bcfc6bd6e..6c476edd1c 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -108,9 +108,7 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error { } augmentDocsCreatePermission(runtime, data) - if err := fallbackDocsCreateURLV2(runtime, data); err != nil { - return err - } + fallbackDocsCreateURLV2(runtime, data) if len(resources) > 0 { doc, _ := data["document"].(map[string]interface{}) if err := finalizeLocalDocResources(runtime, strings.TrimSpace(common.GetString(doc, "document_id")), data, resources); err != nil { @@ -178,22 +176,19 @@ func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string // fallbackDocsCreateURLV2 fills data.document.url with a brand-standard URL // when the OpenAPI response did not include one. Backfills only when missing, // so any tenant-specific URL the backend returned is preserved. -func fallbackDocsCreateURLV2(runtime *common.RuntimeContext, data map[string]interface{}) error { +func fallbackDocsCreateURLV2(runtime *common.RuntimeContext, data map[string]interface{}) { doc, _ := data["document"].(map[string]interface{}) if doc == nil { - return nil + return } if strings.TrimSpace(common.GetString(doc, "url")) != "" { - return nil + return } docID := strings.TrimSpace(common.GetString(doc, "document_id")) if docID == "" { - return nil + return } - if u, err := common.BuildResourceURL(runtime.Ctx(), runtime.Config.Brand, "docx", docID); err != nil { - return err - } else if u != "" { + if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" { doc["url"] = u } - return nil } diff --git a/shortcuts/doc/docs_fetch_im_markdown.go b/shortcuts/doc/docs_fetch_im_markdown.go index 8009244f9f..8ca47cd280 100644 --- a/shortcuts/doc/docs_fetch_im_markdown.go +++ b/shortcuts/doc/docs_fetch_im_markdown.go @@ -4,7 +4,6 @@ package doc import ( - "context" "fmt" "html" "net/url" @@ -104,36 +103,27 @@ func isIMMarkdownFetch(runtime interface{ Str(string) string }) bool { return strings.TrimSpace(runtime.Str("doc-format")) == "im-markdown" } -func applyFetchIMMarkdown(ctx context.Context, data map[string]interface{}, docInput string) error { +func applyFetchIMMarkdown(data map[string]interface{}, docInput string) { doc, ok := data["document"].(map[string]interface{}) if !ok { - return nil + return } content, ok := doc["content"].(string) if !ok { - return nil + return } - imCtx, err := newIMMarkdownContext(ctx, docInput) - if err != nil { - return err - } - doc["content"] = convertToIMMarkdown(content, imCtx) - return nil + doc["content"] = convertToIMMarkdown(content, newIMMarkdownContext(docInput)) } -func newIMMarkdownContext(ctx context.Context, docInput string) (imMarkdownContext, error) { +func newIMMarkdownContext(docInput string) imMarkdownContext { base := "https://larkoffice.com" raw := strings.TrimSpace(docInput) if extracted, ok := imMarkdownBaseURLFromInput(raw); ok { base = extracted } else { - var err error - base, err = urlrewrite.Rewrite(ctx, base) - if err != nil { - return imMarkdownContext{}, err - } + base = urlrewrite.Rewrite(base) } - return imMarkdownContext{baseURL: base}, nil + return imMarkdownContext{baseURL: base} } func (c imMarkdownContext) withBlockquote() imMarkdownContext { diff --git a/shortcuts/doc/docs_fetch_im_markdown_test.go b/shortcuts/doc/docs_fetch_im_markdown_test.go index cf19f09e6c..3c8e7de59c 100644 --- a/shortcuts/doc/docs_fetch_im_markdown_test.go +++ b/shortcuts/doc/docs_fetch_im_markdown_test.go @@ -4,7 +4,6 @@ package doc import ( - "context" "reflect" "strings" "testing" @@ -63,9 +62,7 @@ func TestApplyFetchIMMarkdown(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - if err := applyFetchIMMarkdown(context.Background(), tt.data, tt.docInput); err != nil { - t.Fatalf("applyFetchIMMarkdown() error = %v", err) - } + applyFetchIMMarkdown(tt.data, tt.docInput) if !reflect.DeepEqual(tt.data, tt.want) { t.Fatalf("data = %#v, want %#v", tt.data, tt.want) } @@ -1079,10 +1076,7 @@ func TestNewIMMarkdownContextExtractsBaseURL(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - imCtx, err := newIMMarkdownContext(context.Background(), tt.input) - if err != nil { - t.Fatalf("newIMMarkdownContext() error = %v", err) - } + imCtx := newIMMarkdownContext(tt.input) if got := imCtx.baseURL; got != tt.want { t.Fatalf("baseURL = %q, want %q", got, tt.want) } diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index 6743a4b732..a83200e6a0 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -81,9 +81,7 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error { fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning) } if isIMMarkdownFetch(runtime) { - if err := applyFetchIMMarkdown(runtime.Ctx(), data, runtime.Str("doc")); err != nil { - return err - } + applyFetchIMMarkdown(data, runtime.Str("doc")) } runtime.OutFormatRaw(data, nil, func(w io.Writer) { diff --git a/shortcuts/drive/drive_copy.go b/shortcuts/drive/drive_copy.go index e959da69af..c7264c6f5f 100644 --- a/shortcuts/drive/drive_copy.go +++ b/shortcuts/drive/drive_copy.go @@ -123,10 +123,7 @@ var DriveCopy = common.Shortcut{ if copiedToken == "" { return errs.NewInternalError(errs.SubtypeInvalidResponse, "drive copy succeeded but returned no file token (data.file.token)") } - out, err := buildDriveCopyOutput(ctx, runtime, spec, folderToken, data) - if err != nil { - return err - } + out := buildDriveCopyOutput(runtime, spec, folderToken, data) copiedType := common.GetString(data, "file", "type") if copiedType == "" { copiedType = spec.Ref.Type @@ -424,7 +421,7 @@ func buildDriveCopyDryRun(spec driveCopySpec) *common.DryRunAPI { Set("file_token", spec.Ref.Token) } -func buildDriveCopyOutput(ctx context.Context, runtime *common.RuntimeContext, spec driveCopySpec, folderToken string, data map[string]interface{}) (map[string]interface{}, error) { +func buildDriveCopyOutput(runtime *common.RuntimeContext, spec driveCopySpec, folderToken string, data map[string]interface{}) map[string]interface{} { out := map[string]interface{}{ "copied": true, "source_file_token": spec.Ref.Token, @@ -439,9 +436,7 @@ func buildDriveCopyOutput(ctx context.Context, runtime *common.RuntimeContext, s out["file_token"] = token if url := common.GetString(file, "url"); url != "" { out["url"] = url - } else if built, err := common.BuildResourceURL(ctx, runtime.Config.Brand, common.GetString(file, "type"), token); err != nil { - return nil, err - } else if built != "" { + } else if built := common.BuildResourceURL(runtime.Config.Brand, common.GetString(file, "type"), token); built != "" { out["url"] = built } } @@ -451,5 +446,5 @@ func buildDriveCopyOutput(ctx context.Context, runtime *common.RuntimeContext, s if name := common.GetString(file, "name"); name != "" { out["name"] = name } - return out, nil + return out } diff --git a/shortcuts/drive/drive_create_folder.go b/shortcuts/drive/drive_create_folder.go index cb5f8345ba..4cdeec577c 100644 --- a/shortcuts/drive/drive_create_folder.go +++ b/shortcuts/drive/drive_create_folder.go @@ -94,9 +94,7 @@ var DriveCreateFolder = common.Shortcut{ } if url := strings.TrimSpace(common.GetString(data, "url")); url != "" { out["url"] = url - } else if u, err := common.BuildResourceURL(ctx, runtime.Config.Brand, "folder", folderToken); err != nil { - return err - } else if u != "" { + } else if u := common.BuildResourceURL(runtime.Config.Brand, "folder", folderToken); u != "" { out["url"] = u } if grant := common.AutoGrantCurrentUserDrivePermission(runtime, folderToken, "folder"); grant != nil { diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go index 69d04375f3..db4ed401f5 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -194,9 +194,7 @@ func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportPara if statusURL := strings.TrimSpace(status.URL); statusURL != "" { out["url"] = statusURL } else if status.Token != "" { - if u, err := common.BuildResourceURL(ctx, runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); err != nil { - return err - } else if u != "" { + if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" { out["url"] = u } } diff --git a/shortcuts/drive/drive_inspect.go b/shortcuts/drive/drive_inspect.go index e39cce53a4..de3cd22d83 100644 --- a/shortcuts/drive/drive_inspect.go +++ b/shortcuts/drive/drive_inspect.go @@ -141,10 +141,7 @@ var DriveInspect = common.Shortcut{ } // Step 4: Build the resolved URL. - resolvedURL, err := common.BuildResourceURL(ctx, runtime.Config.Brand, docType, docToken) - if err != nil { - return err - } + resolvedURL := common.BuildResourceURL(runtime.Config.Brand, docType, docToken) // Step 5: Build output. result := map[string]interface{}{ diff --git a/shortcuts/drive/drive_permission_get_setting.go b/shortcuts/drive/drive_permission_get_setting.go index ccb035ef4c..cb3be3f388 100644 --- a/shortcuts/drive/drive_permission_get_setting.go +++ b/shortcuts/drive/drive_permission_get_setting.go @@ -164,11 +164,11 @@ func drivePermissionGetSettingTypeAllowed(docType string) bool { return ok } -func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) (string, error) { +func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string { resourceKind, ok := findDrivePermissionGetSettingResourceKind(s.Type) token := strings.TrimSpace(s.Token) if !ok || token == "" { - return "", nil + return "" } brand := core.LarkBrand("") @@ -179,7 +179,7 @@ func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) (stri if brand == core.BrandLark { host = "https://www.larksuite.com" } - return urlrewrite.Rewrite(runtime.Ctx(), host+resourceKind.CanonicalPath+url.PathEscape(token)) + return urlrewrite.Rewrite(host + resourceKind.CanonicalPath + url.PathEscape(token)) } func validateDrivePermissionGetSettingToken(token string) error { @@ -278,16 +278,12 @@ var DrivePermissionGetSetting = common.Shortcut{ ).WithCause(err) } - displayURL, err := spec.url(runtime) - if err != nil { - return err - } out := map[string]interface{}{"permission_public": permissionPublic} runtime.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintf(w, "Type: %s\n", spec.Type) fmt.Fprintf(w, "Token: %s\n", spec.Token) - if displayURL != "" { - fmt.Fprintf(w, "URL: %s\n", displayURL) + if url := spec.url(runtime); url != "" { + fmt.Fprintf(w, "URL: %s\n", url) } fmt.Fprintf(w, "Permission settings:\n%s\n", permissionPublicPretty) }) diff --git a/shortcuts/drive/drive_permission_get_setting_test.go b/shortcuts/drive/drive_permission_get_setting_test.go index 62e7c620a1..8a01608c4a 100644 --- a/shortcuts/drive/drive_permission_get_setting_test.go +++ b/shortcuts/drive/drive_permission_get_setting_test.go @@ -160,10 +160,7 @@ func TestDrivePermissionGetSettingResourceKindsRoundTrip(t *testing.T) { if err != nil { t.Fatalf("read bare-token spec: %v", err) } - resourceURL, err := bareSpec.url(bareRuntime) - if err != nil { - t.Fatalf("build resource URL: %v", err) - } + resourceURL := bareSpec.url(bareRuntime) if resourceURL == "" { t.Fatalf("resource URL is empty for allowed type %q", kind.Type) } @@ -195,10 +192,7 @@ func TestDrivePermissionGetSettingResourceURLUsesConfiguredBrand(t *testing.T) { if err != nil { t.Fatalf("read spec: %v", err) } - got, err := spec.url(runtime) - if err != nil { - t.Fatalf("build resource URL: %v", err) - } + got := spec.url(runtime) if want := "https://www.larksuite.com/page/appMetaTok"; got != want { t.Fatalf("resource URL = %q, want %q", got, want) } diff --git a/shortcuts/drive/drive_update_title.go b/shortcuts/drive/drive_update_title.go index aa16e5a918..68f44db4d7 100644 --- a/shortcuts/drive/drive_update_title.go +++ b/shortcuts/drive/drive_update_title.go @@ -143,11 +143,7 @@ var DriveUpdateTitle = common.Shortcut{ return decorateDriveUpdateTitleError(err, spec) } - out, err := buildDriveUpdateTitleOutput(ctx, runtime, spec, guard) - if err != nil { - return err - } - runtime.Out(out, nil) + runtime.Out(buildDriveUpdateTitleOutput(runtime, spec, guard), nil) return nil }, } @@ -490,16 +486,14 @@ func buildDriveUpdateTitleDryRun(spec driveUpdateTitleSpec) *common.DryRunAPI { // buildDriveUpdateTitleOutput reports the applied title: the endpoint answers // with an empty data object, so the submitted state plus what the extension // guard read are the only ground truth available without a follow-up read. -func buildDriveUpdateTitleOutput(ctx context.Context, runtime *common.RuntimeContext, spec driveUpdateTitleSpec, guard driveUpdateTitleGuard) (map[string]interface{}, error) { +func buildDriveUpdateTitleOutput(runtime *common.RuntimeContext, spec driveUpdateTitleSpec, guard driveUpdateTitleGuard) map[string]interface{} { out := map[string]interface{}{ "updated": true, "file_token": spec.Ref.Token, "type": spec.Ref.Type, "title": spec.Title, } - if url, err := common.BuildResourceURL(ctx, runtime.Config.Brand, spec.Ref.Type, spec.Ref.Token); err != nil { - return nil, err - } else if url != "" { + if url := common.BuildResourceURL(runtime.Config.Brand, spec.Ref.Type, spec.Ref.Token); url != "" { out["url"] = url } // previous_title makes a wrong rename reversible in one follow-up command. @@ -509,7 +503,7 @@ func buildDriveUpdateTitleOutput(ctx context.Context, runtime *common.RuntimeCon if guard.ExtensionAppended != "" { out["extension_appended"] = guard.ExtensionAppended } - return out, nil + return out } // decorateDriveUpdateTitleError adds command-level recovery guidance to the API diff --git a/shortcuts/im/chat_app_link.go b/shortcuts/im/chat_app_link.go index 049e3fcb24..05be024428 100644 --- a/shortcuts/im/chat_app_link.go +++ b/shortcuts/im/chat_app_link.go @@ -4,7 +4,6 @@ package im import ( - "context" "net/url" "strings" @@ -13,36 +12,33 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -func addChatAppLinks(chats []map[string]interface{}, runtime *common.RuntimeContext) error { +func addChatAppLinks(chats []map[string]interface{}, runtime *common.RuntimeContext) { if runtime == nil || runtime.Config == nil { - return nil + return } for _, chat := range chats { - if link, err := assembleChatAppLink(runtime.Ctx(), chat["chat_id"], runtime.Config.Brand); err != nil { - return err - } else if link != "" { + if link := assembleChatAppLink(chat["chat_id"], runtime.Config.Brand); link != "" { chat["chat_app_link"] = link } } - return nil } -func assembleChatAppLink(ctx context.Context, rawChatID interface{}, brand core.LarkBrand) (string, error) { +func assembleChatAppLink(rawChatID interface{}, brand core.LarkBrand) string { chatID, _ := rawChatID.(string) chatID = strings.TrimSpace(chatID) if !strings.HasPrefix(chatID, "oc_") { - return "", nil + return "" } domain := resolveChatAppLinkDomain(brand) if domain == "" { - return "", nil + return "" } u := &url.URL{Scheme: "https", Host: domain, Path: "/client/chat/open"} q := url.Values{} q.Set("openChatId", chatID) u.RawQuery = q.Encode() - return urlrewrite.Rewrite(ctx, u.String()) + return urlrewrite.Rewrite(u.String()) } func resolveChatAppLinkDomain(brand core.LarkBrand) string { diff --git a/shortcuts/im/chat_app_link_test.go b/shortcuts/im/chat_app_link_test.go index 8b92b9f1d5..04c25f47be 100644 --- a/shortcuts/im/chat_app_link_test.go +++ b/shortcuts/im/chat_app_link_test.go @@ -47,10 +47,7 @@ func TestAssembleChatAppLink(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := assembleChatAppLink(context.Background(), tt.chatID, tt.brand) - if err != nil { - t.Fatalf("assembleChatAppLink() error = %v", err) - } + got := assembleChatAppLink(tt.chatID, tt.brand) if got != tt.want { t.Fatalf("assembleChatAppLink() = %q, want %q", got, tt.want) } diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go index d25daaa192..5395167c25 100644 --- a/shortcuts/im/convert_lib/content_convert.go +++ b/shortcuts/im/convert_lib/content_convert.go @@ -4,7 +4,6 @@ package convertlib import ( - "context" "encoding/json" "fmt" "math" @@ -146,12 +145,6 @@ func FormatMessageItemWithMergePrefetch(m map[string]interface{}, runtime *commo return formatMessageItem(m, runtime, nameCache, mergePrefetch, false) } -// FormatMessageItemWithMergePrefetchE is the error-returning variant for -// command execution paths that synthesize an app link. -func FormatMessageItemWithMergePrefetchE(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) (map[string]interface{}, error) { - return formatMessageItemE(m, runtime, nameCache, mergePrefetch, false) -} - // FormatMessageItemWithMergePrefetchOpts is FormatMessageItemWithMergePrefetch // with an explicit extractResources gate. When extractResources is true and // the message carries downloadable resources, a "resources" block (ref list @@ -162,18 +155,7 @@ func FormatMessageItemWithMergePrefetchOpts(m map[string]interface{}, runtime *c return formatMessageItem(m, runtime, nameCache, mergePrefetch, extractResources) } -// FormatMessageItemWithMergePrefetchOptsE is the error-returning variant for -// command execution paths that synthesize an app link. -func FormatMessageItemWithMergePrefetchOptsE(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, extractResources bool) (map[string]interface{}, error) { - return formatMessageItemE(m, runtime, nameCache, mergePrefetch, extractResources) -} - func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, extractResources bool) map[string]interface{} { - msg, _ := formatMessageItemE(m, runtime, nameCache, mergePrefetch, extractResources) - return msg -} - -func formatMessageItemE(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, extractResources bool) (map[string]interface{}, error) { msgType, _ := m["msg_type"].(string) messageId, _ := m["message_id"].(string) mentions, _ := m["mentions"].([]interface{}) @@ -241,11 +223,7 @@ func formatMessageItemE(m map[string]interface{}, runtime *common.RuntimeContext appLink, _ := m["message_app_link"].(string) appLink = strings.TrimSpace(appLink) if appLink == "" && runtime != nil && runtime.Config != nil { - var err error - appLink, err = assembleMessageAppLink(runtime.Ctx(), m, runtime.Config.Brand) - if err != nil { - return nil, err - } + appLink = assembleMessageAppLink(m, runtime.Config.Brand) } if appLink != "" { msg["message_app_link"] = appLink @@ -280,13 +258,13 @@ func formatMessageItemE(m map[string]interface{}, runtime *common.RuntimeContext } } - return msg, nil + return msg } -func assembleMessageAppLink(ctx context.Context, m map[string]interface{}, brand core.LarkBrand) (string, error) { +func assembleMessageAppLink(m map[string]interface{}, brand core.LarkBrand) string { domain := resolveAppLinkDomain(brand) if domain == "" { - return "", nil + return "" } chatID, _ := m["chat_id"].(string) @@ -306,7 +284,7 @@ func assembleMessageAppLink(ctx context.Context, m map[string]interface{}, brand q.Set("open_chat_id", chatID) q.Set("thread_position", threadPos) u.RawQuery = q.Encode() - return urlrewrite.Rewrite(ctx, u.String()) + return urlrewrite.Rewrite(u.String()) } if chatID != "" && okMsgPos { u := &url.URL{Scheme: "https", Host: domain, Path: "/client/chat/open"} @@ -314,9 +292,9 @@ func assembleMessageAppLink(ctx context.Context, m map[string]interface{}, brand q.Set("openChatId", chatID) q.Set("position", msgPos) u.RawQuery = q.Encode() - return urlrewrite.Rewrite(ctx, u.String()) + return urlrewrite.Rewrite(u.String()) } - return "", nil + return "" } func normalizeMessagePosition(v interface{}) (string, bool) { diff --git a/shortcuts/im/convert_lib/content_media_misc_test.go b/shortcuts/im/convert_lib/content_media_misc_test.go index 8d346dc74a..c207b77c85 100644 --- a/shortcuts/im/convert_lib/content_media_misc_test.go +++ b/shortcuts/im/convert_lib/content_media_misc_test.go @@ -4,7 +4,6 @@ package convertlib import ( - "context" "encoding/json" "math" "net/url" @@ -357,10 +356,7 @@ func TestAssembleMessageAppLink_EncodesQueryValues(t *testing.T) { "chat_id": "oc_1+2/3", "message_position": 12, } - gotChat, err := assembleMessageAppLink(context.Background(), chat, core.BrandFeishu) - if err != nil { - t.Fatalf("assembleMessageAppLink() error = %v", err) - } + gotChat := assembleMessageAppLink(chat, core.BrandFeishu) assertURLHasQuery(t, gotChat, "applink.feishu.cn", "/client/chat/open", map[string]string{ "openChatId": "oc_1+2/3", "position": "12", @@ -372,10 +368,7 @@ func TestAssembleMessageAppLink_EncodesQueryValues(t *testing.T) { "thread_id": "omt_1+2/3", "thread_message_position": -1, } - gotThread, err := assembleMessageAppLink(context.Background(), thread, core.BrandFeishu) - if err != nil { - t.Fatalf("assembleMessageAppLink() error = %v", err) - } + gotThread := assembleMessageAppLink(thread, core.BrandFeishu) assertURLHasQuery(t, gotThread, "applink.feishu.cn", "/client/thread/open", map[string]string{ "open_thread_id": "omt_1+2/3", "open_chat_id": "oc_1+2/3", diff --git a/shortcuts/im/im_chat_create.go b/shortcuts/im/im_chat_create.go index 3d8c0ee92e..28d6b61351 100644 --- a/shortcuts/im/im_chat_create.go +++ b/shortcuts/im/im_chat_create.go @@ -126,9 +126,7 @@ var ImChatCreate = common.Shortcut{ "external": resData["external"], } if runtime.Config != nil { - if link, err := assembleChatAppLink(ctx, resData["chat_id"], runtime.Config.Brand); err != nil { - return err - } else if link != "" { + if link := assembleChatAppLink(resData["chat_id"], runtime.Config.Brand); link != "" { outData["chat_app_link"] = link } } diff --git a/shortcuts/im/im_chat_list.go b/shortcuts/im/im_chat_list.go index 1e4061fd58..4e3536e7df 100644 --- a/shortcuts/im/im_chat_list.go +++ b/shortcuts/im/im_chat_list.go @@ -142,9 +142,7 @@ var ImChatList = common.Shortcut{ } items = mfOut.Chats pagination.Items = len(items) - if err := addChatAppLinks(items, runtime); err != nil { - return err - } + addChatAppLinks(items, runtime) // Presentation stage: business data stays backward compatible while the // output layer carries the authoritative pagination outcome for every diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index f1024044e4..8aa1d49f25 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -160,10 +160,7 @@ var ImChatMessageList = common.Shortcut{ downloadResources := runtime.Bool("download-resources") messages := make([]map[string]interface{}, 0, len(rawItems)) for _, m := range result.items { - message, err := convertlib.FormatMessageItemWithMergePrefetchOptsE(m, runtime, nameCache, mergePrefetch, downloadResources) - if err != nil { - return err - } + message := convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources) messages = append(messages, message) } diff --git a/shortcuts/im/im_messages_mget.go b/shortcuts/im/im_messages_mget.go index a1a740d4f8..cf0ac82d49 100644 --- a/shortcuts/im/im_messages_mget.go +++ b/shortcuts/im/im_messages_mget.go @@ -83,10 +83,7 @@ var ImMessagesMGet = common.Shortcut{ messages := make([]map[string]interface{}, 0, len(rawItems)) for _, item := range rawItems { m, _ := item.(map[string]interface{}) - message, err := convertlib.FormatMessageItemWithMergePrefetchOptsE(m, runtime, nameCache, mergePrefetch, downloadResources) - if err != nil { - return err - } + message := convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources) messages = append(messages, message) } diff --git a/shortcuts/im/im_messages_search.go b/shortcuts/im/im_messages_search.go index ce06fb04fd..fb2a440a54 100644 --- a/shortcuts/im/im_messages_search.go +++ b/shortcuts/im/im_messages_search.go @@ -178,10 +178,7 @@ var ImMessagesSearch = common.Shortcut{ chatId, _ := m["chat_id"].(string) // Reuse unified content converter - msg, err := convertlib.FormatMessageItemWithMergePrefetchE(m, runtime, nameCache, mergePrefetch) - if err != nil { - return err - } + msg := convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch) if chatId != "" { msg["chat_id"] = chatId } diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index dae5823cb5..46d86869b1 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -133,10 +133,7 @@ var ImThreadsMessagesList = common.Shortcut{ downloadResources := runtime.Bool("download-resources") messages := make([]map[string]interface{}, 0, len(rawItems)) for _, m := range result.items { - message, err := convertlib.FormatMessageItemWithMergePrefetchOptsE(m, runtime, nameCache, mergePrefetch, downloadResources) - if err != nil { - return err - } + message := convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources) messages = append(messages, message) } diff --git a/shortcuts/mail/large_attachment.go b/shortcuts/mail/large_attachment.go index 91daad78ed..0df0a8d6b4 100644 --- a/shortcuts/mail/large_attachment.go +++ b/shortcuts/mail/large_attachment.go @@ -272,10 +272,10 @@ func buildLargeAttachmentItems(brand core.LarkBrand, lang string, results []larg var items strings.Builder for _, att := range results { fmt.Fprintf(&items, largeAttItemTpl, - htmlEscape(iconCDN+fileTypeIcon(att.FileName)), + htmlEscape(urlrewrite.Rewrite(iconCDN+fileTypeIcon(att.FileName))), htmlEscape(att.FileName), htmlEscape(common.FormatSize(att.FileSize)), - htmlEscape(buildLargeAttachmentPreviewURL(brand, att.FileToken)), + htmlEscape(urlrewrite.Rewrite(buildLargeAttachmentPreviewURL(brand, att.FileToken))), htmlEscape(att.FileToken), downloadText, ) @@ -283,43 +283,6 @@ func buildLargeAttachmentItems(brand core.LarkBrand, lang string, results []larg return items.String() } -// buildRewrittenLargeAttachmentItems applies the optional URL rewrite extension -// to the generated attachment preview and icon URLs before putting them in the -// message body. -func buildRewrittenLargeAttachmentItems(ctx context.Context, brand core.LarkBrand, lang string, results []largeAttachmentResult) (string, error) { - if len(results) == 0 { - return "", nil - } - downloadText := "Download" - if strings.HasPrefix(lang, "zh") { - downloadText = "下载" - } - iconCDN := iconCDNCN - if brand == core.BrandLark { - iconCDN = iconCDNEN - } - var items strings.Builder - for _, att := range results { - iconURL, err := urlrewrite.Rewrite(ctx, iconCDN+fileTypeIcon(att.FileName)) - if err != nil { - return "", err - } - previewURL, err := urlrewrite.Rewrite(ctx, buildLargeAttachmentPreviewURL(brand, att.FileToken)) - if err != nil { - return "", err - } - fmt.Fprintf(&items, largeAttItemTpl, - htmlEscape(iconURL), - htmlEscape(att.FileName), - htmlEscape(common.FormatSize(att.FileSize)), - htmlEscape(previewURL), - htmlEscape(att.FileToken), - downloadText, - ) - } - return items.String(), nil -} - func buildLargeAttachmentHTML(brand core.LarkBrand, lang string, results []largeAttachmentResult) string { if len(results) == 0 { return "" @@ -336,26 +299,6 @@ func buildLargeAttachmentHTML(brand core.LarkBrand, lang string, results []large return fmt.Sprintf(largeAttContainerTpl, timestamp, title, buildLargeAttachmentItems(brand, lang, results)) } -func buildRewrittenLargeAttachmentHTML(ctx context.Context, brand core.LarkBrand, lang string, results []largeAttachmentResult) (string, error) { - if len(results) == 0 { - return "", nil - } - appName := brandDisplayName(brand, lang) - title := "Large file from " + appName + " Mail" - if strings.HasPrefix(lang, "zh") { - title = "来自" + appName + "邮箱的超大附件" - } - timestamp := fmt.Sprintf("%d", time.Now().UnixMilli()) - if len(timestamp) > 9 { - timestamp = timestamp[:9] - } - items, err := buildRewrittenLargeAttachmentItems(ctx, brand, lang, results) - if err != nil { - return "", err - } - return fmt.Sprintf(largeAttContainerTpl, timestamp, title, items), nil -} - func buildLargeAttachmentPlainText(brand core.LarkBrand, lang string, results []largeAttachmentResult) string { if len(results) == 0 { return "" @@ -378,7 +321,7 @@ func buildLargeAttachmentPlainText(brand core.LarkBrand, lang string, results [] sb.WriteString("\n") sb.WriteString(common.FormatSize(att.FileSize)) sb.WriteString("\n") - sb.WriteString(downloadText + ": " + buildLargeAttachmentPreviewURL(brand, att.FileToken)) + sb.WriteString(downloadText + ": " + urlrewrite.Rewrite(buildLargeAttachmentPreviewURL(brand, att.FileToken))) if i < len(results)-1 { sb.WriteString("\n\n") } else { @@ -388,42 +331,6 @@ func buildLargeAttachmentPlainText(brand core.LarkBrand, lang string, results [] return sb.String() } -func buildRewrittenLargeAttachmentPlainText(ctx context.Context, brand core.LarkBrand, lang string, results []largeAttachmentResult) (string, error) { - if len(results) == 0 { - return "", nil - } - - appName := brandDisplayName(brand, lang) - title := "Large file from " + appName + " Mail" - downloadText := "Download" - if strings.HasPrefix(lang, "zh") { - title = "来自" + appName + "邮箱的超大附件" - downloadText = "下载" - } - - var sb strings.Builder - sb.WriteString("\n") - sb.WriteString(title) - sb.WriteString("\n") - for i, att := range results { - previewURL, err := urlrewrite.Rewrite(ctx, buildLargeAttachmentPreviewURL(brand, att.FileToken)) - if err != nil { - return "", err - } - sb.WriteString(att.FileName) - sb.WriteString("\n") - sb.WriteString(common.FormatSize(att.FileSize)) - sb.WriteString("\n") - sb.WriteString(downloadText + ": " + previewURL) - if i < len(results)-1 { - sb.WriteString("\n\n") - } else { - sb.WriteString("\n") - } - } - return sb.String(), nil -} - // fileTypeIcon returns the CDN icon filename for a given attachment filename, // matching desktop's AttachmentIconPath (mail-editor/src/plugins/bigAttachment/utils.ts). func fileTypeIcon(filename string) string { @@ -537,16 +444,10 @@ func processLargeAttachments( } if htmlBody != "" { - largeHTML, err := buildRewrittenLargeAttachmentHTML(ctx, runtime.Config.Brand, resolveLang(runtime), results) - if err != nil { - return bld, err - } + largeHTML := buildLargeAttachmentHTML(runtime.Config.Brand, resolveLang(runtime), results) bld = bld.HTMLBody([]byte(draftpkg.InsertBeforeQuoteOrAppend(htmlBody, largeHTML))) } else { - largeText, err := buildRewrittenLargeAttachmentPlainText(ctx, runtime.Config.Brand, resolveLang(runtime), results) - if err != nil { - return bld, err - } + largeText := buildLargeAttachmentPlainText(runtime.Config.Brand, resolveLang(runtime), results) bld = bld.TextBody([]byte(textBody + largeText)) } @@ -637,61 +538,6 @@ func ensureLargeAttachmentCards(runtime *common.RuntimeContext, snapshot *draftp } } -// ensureRewrittenLargeAttachmentCards is the command-path counterpart to -// ensureLargeAttachmentCards. It preserves the legacy helper for snapshot-only -// callers while ensuring newly generated links use the configured URL rewriter. -func ensureRewrittenLargeAttachmentCards(ctx context.Context, runtime *common.RuntimeContext, snapshot *draftpkg.DraftSnapshot) error { - summaries := draftpkg.ParseLargeAttachmentSummariesFromHeader(snapshot.Headers) - if len(summaries) == 0 { - return nil - } - - brand := core.BrandFeishu - if runtime.Config != nil { - brand = runtime.Config.Brand - } - lang := "zh_cn" - if runtime.Factory != nil { - lang = resolveLang(runtime) - } - - htmlPart := draftpkg.FindHTMLBodyPart(snapshot.Body) - if htmlPart != nil { - existingCards := draftpkg.ParseLargeAttachmentItemsFromHTML(string(htmlPart.Body)) - var missing []largeAttachmentResult - for _, s := range summaries { - if _, exists := existingCards[s.Token]; !exists { - missing = append(missing, largeAttachmentResult{FileName: s.FileName, FileSize: s.SizeBytes, FileToken: s.Token}) - } - } - if len(missing) == 0 { - return nil - } - return injectRewrittenLargeAttachmentHTMLIntoSnapshot(ctx, snapshot, brand, lang, missing) - } - - textPart := draftpkg.FindTextBodyPart(snapshot.Body) - if textPart == nil { - return nil - } - bodyText := string(textPart.Body) - var missing []largeAttachmentResult - for _, s := range summaries { - if !strings.Contains(bodyText, s.Token) { - missing = append(missing, largeAttachmentResult{FileName: s.FileName, FileSize: s.SizeBytes, FileToken: s.Token}) - } - } - if len(missing) == 0 { - return nil - } - largeText, err := buildRewrittenLargeAttachmentPlainText(ctx, brand, lang, missing) - if err != nil { - return err - } - injectLargeAttachmentTextIntoSnapshot(snapshot, largeText) - return nil -} - // preprocessLargeAttachmentsForDraftEdit scans a draft-edit patch for // add_attachment ops, classifies the files (normal vs oversized based on // the snapshot's current EML size), uploads oversized files, injects the @@ -706,9 +552,7 @@ func preprocessLargeAttachmentsForDraftEdit( // Reconstruct missing large attachment HTML cards from the server-format // header metadata. Must run before normalizeLargeAttachmentHeader which // discards file_name/file_size. - if err := ensureRewrittenLargeAttachmentCards(ctx, runtime, snapshot); err != nil { - return patch, err - } + ensureLargeAttachmentCards(runtime, snapshot) // Always normalize server-format headers to CLI format so every code // path below (and every early return) sends the format the server @@ -786,14 +630,9 @@ func preprocessLargeAttachmentsForDraftEdit( } if hasHTML { - if err := injectRewrittenLargeAttachmentHTMLIntoSnapshot(ctx, snapshot, runtime.Config.Brand, resolveLang(runtime), results); err != nil { - return patch, err - } + injectLargeAttachmentHTMLIntoSnapshot(snapshot, runtime.Config.Brand, resolveLang(runtime), results) } else { - largeText, err := buildRewrittenLargeAttachmentPlainText(ctx, runtime.Config.Brand, resolveLang(runtime), results) - if err != nil { - return patch, err - } + largeText := buildLargeAttachmentPlainText(runtime.Config.Brand, resolveLang(runtime), results) injectLargeAttachmentTextIntoSnapshot(snapshot, largeText) } @@ -927,47 +766,6 @@ func injectLargeAttachmentHTMLIntoSnapshot(snapshot *draftpkg.DraftSnapshot, bra htmlPart.Dirty = true } -func injectRewrittenLargeAttachmentHTMLIntoSnapshot(ctx context.Context, snapshot *draftpkg.DraftSnapshot, brand core.LarkBrand, lang string, results []largeAttachmentResult) error { - if len(results) == 0 { - return nil - } - htmlPart := draftpkg.FindHTMLBodyPart(snapshot.Body) - if htmlPart == nil { - if snapshot.Body != nil { - return nil - } - html, err := buildRewrittenLargeAttachmentHTML(ctx, brand, lang, results) - if err != nil { - return err - } - snapshot.Body = &draftpkg.Part{ - MediaType: "text/html", - Body: []byte(html), - Dirty: true, - } - return nil - } - - currentHTML := string(htmlPart.Body) - if draftpkg.HTMLContainsLargeAttachment(currentHTML) { - itemsHTML, err := buildRewrittenLargeAttachmentItems(ctx, brand, lang, results) - if err != nil { - return err - } - before, card, after := draftpkg.SplitAtLargeAttachment(currentHTML) - merged := card[:len(card)-len("")] + itemsHTML + "" - htmlPart.Body = []byte(before + merged + after) - } else { - fullHTML, err := buildRewrittenLargeAttachmentHTML(ctx, brand, lang, results) - if err != nil { - return err - } - htmlPart.Body = []byte(draftpkg.InsertBeforeQuoteOrAppend(currentHTML, fullHTML)) - } - htmlPart.Dirty = true - return nil -} - func injectLargeAttachmentTextIntoSnapshot(snapshot *draftpkg.DraftSnapshot, largeText string) { textPart := draftpkg.FindTextBodyPart(snapshot.Body) if textPart == nil { diff --git a/shortcuts/mail/large_attachment_test.go b/shortcuts/mail/large_attachment_test.go index eedefc963e..4f2e714462 100644 --- a/shortcuts/mail/large_attachment_test.go +++ b/shortcuts/mail/large_attachment_test.go @@ -147,16 +147,13 @@ func TestBuildLargeAttachmentPreviewURL(t *testing.T) { } } -func TestBuildRewrittenLargeAttachmentContent(t *testing.T) { +func TestBuildLargeAttachmentContentRewritesURLs(t *testing.T) { withLargeAttachmentURLRewriter(t, largeAttachmentRewriteFunc(func(rawURL string) string { return strings.Replace(rawURL, "https://", "https://mirror.example/", 1) })) results := []largeAttachmentResult{{FileName: "report.pdf", FileSize: 1024, FileToken: "token"}} - html, err := buildRewrittenLargeAttachmentHTML(context.Background(), core.BrandFeishu, "en_us", results) - if err != nil { - t.Fatalf("buildRewrittenLargeAttachmentHTML() error: %v", err) - } + html := buildLargeAttachmentHTML(core.BrandFeishu, "en_us", results) if !strings.Contains(html, "https://mirror.example/www.feishu.cn/mail/page/attachment?token=token") { t.Fatalf("HTML does not contain rewritten preview URL: %s", html) } @@ -164,23 +161,12 @@ func TestBuildRewrittenLargeAttachmentContent(t *testing.T) { t.Fatalf("HTML does not contain rewritten icon URL: %s", html) } - text, err := buildRewrittenLargeAttachmentPlainText(context.Background(), core.BrandFeishu, "en_us", results) - if err != nil { - t.Fatalf("buildRewrittenLargeAttachmentPlainText() error: %v", err) - } + text := buildLargeAttachmentPlainText(core.BrandFeishu, "en_us", results) if !strings.Contains(text, "https://mirror.example/www.feishu.cn/mail/page/attachment?token=token") { t.Fatalf("text does not contain rewritten preview URL: %s", text) } } -func TestBuildRewrittenLargeAttachmentContentRejectsInvalidURL(t *testing.T) { - withLargeAttachmentURLRewriter(t, largeAttachmentRewriteFunc(func(string) string { return "invalid" })) - results := []largeAttachmentResult{{FileName: "report.pdf", FileSize: 1024, FileToken: "token"}} - if _, err := buildRewrittenLargeAttachmentHTML(context.Background(), core.BrandFeishu, "en_us", results); err == nil { - t.Fatal("buildRewrittenLargeAttachmentHTML() error = nil, want invalid rewrite error") - } -} - func TestBuildLargeAttachmentHTML(t *testing.T) { results := []largeAttachmentResult{ {FileName: "report.pdf", FileSize: 50 * 1024 * 1024, FileToken: "tok_abc"}, diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 71b114eb39..306b7db5d4 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -472,16 +472,10 @@ var MailForward = common.Shortcut{ } if composedHTMLBody != "" { - largeHTML, err := buildRewrittenLargeAttachmentHTML(ctx, runtime.Config.Brand, resolveLang(runtime), uploadResults) - if err != nil { - return err - } + largeHTML := buildLargeAttachmentHTML(runtime.Config.Brand, resolveLang(runtime), uploadResults) bld = bld.HTMLBody([]byte(draftpkg.InsertBeforeQuoteOrAppend(composedHTMLBody, largeHTML))) } else { - largeText, err := buildRewrittenLargeAttachmentPlainText(ctx, runtime.Config.Brand, resolveLang(runtime), uploadResults) - if err != nil { - return err - } + largeText := buildLargeAttachmentPlainText(runtime.Config.Brand, resolveLang(runtime), uploadResults) bld = bld.TextBody([]byte(composedTextBody + largeText)) } diff --git a/shortcuts/okr/okr_progress_create.go b/shortcuts/okr/okr_progress_create.go index 8014f55551..9ff36df970 100644 --- a/shortcuts/okr/okr_progress_create.go +++ b/shortcuts/okr/okr_progress_create.go @@ -79,11 +79,7 @@ func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createPro sourceURL := runtime.Str("source-url") if sourceURL == "" { - var err error - sourceURL, err = urlrewrite.Rewrite(runtime.Ctx(), core.ResolveOpenBaseURL(runtime.Config.Brand)+"/app") - if err != nil { - return nil, err - } + sourceURL = urlrewrite.Rewrite(core.ResolveOpenBaseURL(runtime.Config.Brand) + "/app") } var progressRate *ProgressRateV1 diff --git a/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go index 42affd5c66..41c077d6ee 100644 --- a/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go +++ b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go @@ -181,9 +181,7 @@ var SheetCreate = common.Shortcut{ url, _ := spreadsheet["url"].(string) if url = strings.TrimSpace(url); url != "" { out["url"] = url - } else if u, err := common.BuildResourceURL(ctx, runtime.Config.Brand, "sheet", token); err != nil { - return err - } else if u != "" { + } else if u := common.BuildResourceURL(runtime.Config.Brand, "sheet", token); u != "" { out["url"] = u } if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil { diff --git a/shortcuts/slides/slides_create.go b/shortcuts/slides/slides_create.go index d254b44d82..56b5684676 100644 --- a/shortcuts/slides/slides_create.go +++ b/shortcuts/slides/slides_create.go @@ -213,10 +213,7 @@ var SlidesCreate = common.Shortcut{ // brand-standard URL only when the API omits it. presentationURL := common.GetString(data, "url") if presentationURL == "" { - presentationURL, err = common.BuildResourceURL(ctx, runtime.Config.Brand, "slides", presentationID) - if err != nil { - return err - } + presentationURL = common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID) } if presentationURL != "" { result["url"] = presentationURL diff --git a/shortcuts/vc/helpers.go b/shortcuts/vc/helpers.go index 59df77384a..62eb71b934 100644 --- a/shortcuts/vc/helpers.go +++ b/shortcuts/vc/helpers.go @@ -38,10 +38,7 @@ func normalizeMeetingQueryPermissionError(runtime *common.RuntimeContext, err er permissionErr.WithHint("ask the app developer to enable scope %s", meetingQueryBotScope) permissionErr.WithMissingScopes(meetingQueryBotScope).WithIdentity(string(core.AsBot)) if runtime.Config != nil { - consoleURL, rewriteErr := registry.BuildConsoleScopeURL(runtime.Ctx(), runtime.Config.Brand, runtime.Config.AppID, meetingQueryBotScope) - if rewriteErr != nil { - return rewriteErr - } + consoleURL := registry.BuildConsoleScopeURL(runtime.Config.Brand, runtime.Config.AppID, meetingQueryBotScope) if consoleURL != "" { permissionErr.WithConsoleURL(consoleURL) } diff --git a/shortcuts/wiki/wiki_helpers.go b/shortcuts/wiki/wiki_helpers.go index 83a1a41c5f..59f10dc301 100644 --- a/shortcuts/wiki/wiki_helpers.go +++ b/shortcuts/wiki/wiki_helpers.go @@ -4,7 +4,6 @@ package wiki import ( - "context" "strings" "github.com/larksuite/cli/errs" @@ -19,14 +18,14 @@ import ( // // Shared by +node-create and +node-copy, hence kept here rather than in either // command's file. -func wikiNodeURL(ctx context.Context, brand core.LarkBrand, node *wikiNodeRecord) (string, error) { +func wikiNodeURL(brand core.LarkBrand, node *wikiNodeRecord) string { if node == nil { - return "", nil + return "" } if u := strings.TrimSpace(node.URL); u != "" { - return u, nil + return u } - return common.BuildResourceURL(ctx, brand, "wiki", node.NodeToken) + return common.BuildResourceURL(brand, "wiki", node.NodeToken) } func appendWikiProblemHint(err error, hint string) error { diff --git a/shortcuts/wiki/wiki_node_copy.go b/shortcuts/wiki/wiki_node_copy.go index 6a3235c8aa..a6632515be 100644 --- a/shortcuts/wiki/wiki_node_copy.go +++ b/shortcuts/wiki/wiki_node_copy.go @@ -107,9 +107,7 @@ var WikiNodeCopy = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Copied to node %s in space %s\n", common.MaskToken(node.NodeToken), common.MaskToken(node.SpaceID)) out := wikiNodeCopyOutput(node) - if u, err := wikiNodeURL(ctx, runtime.Config.Brand, node); err != nil { - return err - } else if u != "" { + if u := wikiNodeURL(runtime.Config.Brand, node); u != "" { out["url"] = u } runtime.OutFormat(out, nil, func(w io.Writer) { diff --git a/shortcuts/wiki/wiki_node_create.go b/shortcuts/wiki/wiki_node_create.go index cd3fb4cfbc..d5ff12e298 100644 --- a/shortcuts/wiki/wiki_node_create.go +++ b/shortcuts/wiki/wiki_node_create.go @@ -95,11 +95,7 @@ var WikiNodeCreate = common.Shortcut{ } fmt.Fprintf(runtime.IO().ErrOut, "Created wiki node in space %s via %s.\n", execution.ResolvedSpace.SpaceID, execution.ResolvedSpace.ResolvedBy) - out, err := augmentWikiNodeCreateOutput(ctx, runtime, execution) - if err != nil { - return err - } - runtime.Out(out, nil) + runtime.Out(augmentWikiNodeCreateOutput(runtime, execution), nil) return nil }, } @@ -600,19 +596,17 @@ func wikiNodeCreateOutput(execution *wikiNodeCreateExecution) map[string]interfa } } -func augmentWikiNodeCreateOutput(ctx context.Context, runtime *common.RuntimeContext, execution *wikiNodeCreateExecution) (map[string]interface{}, error) { +func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wikiNodeCreateExecution) map[string]interface{} { if execution == nil || execution.Node == nil { - return map[string]interface{}{}, nil + return map[string]interface{}{} } out := wikiNodeCreateOutput(execution) if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil { out["permission_grant"] = grant } - if u, err := wikiNodeURL(ctx, runtime.Config.Brand, execution.Node); err != nil { - return nil, err - } else if u != "" { + if u := wikiNodeURL(runtime.Config.Brand, execution.Node); u != "" { out["url"] = u } - return out, nil + return out } diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index 37d10762c7..1c1b9b87ce 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -846,11 +846,11 @@ func TestWikiNodeCreateUserSkipsPermissionGrantAugmentation(t *testing.T) { func TestAugmentWikiNodeCreateOutputReturnsEmptyMapForNilInput(t *testing.T) { t.Parallel() - if got, err := augmentWikiNodeCreateOutput(context.Background(), nil, nil); err != nil || len(got) != 0 { + if got := augmentWikiNodeCreateOutput(nil, nil); len(got) != 0 { t.Fatalf("augmentWikiNodeCreateOutput(nil, nil) = %#v, want empty map", got) } - if got, err := augmentWikiNodeCreateOutput(context.Background(), nil, &wikiNodeCreateExecution{}); err != nil || len(got) != 0 { + if got := augmentWikiNodeCreateOutput(nil, &wikiNodeCreateExecution{}); len(got) != 0 { t.Fatalf("augmentWikiNodeCreateOutput(nil, empty execution) = %#v, want empty map", got) } } @@ -892,10 +892,7 @@ func TestWikiNodeURL(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got, err := wikiNodeURL(context.Background(), core.BrandFeishu, tc.node) - if err != nil { - t.Fatalf("wikiNodeURL() error = %v", err) - } + got := wikiNodeURL(core.BrandFeishu, tc.node) if got != tc.want { t.Fatalf("wikiNodeURL() = %q, want %q", got, tc.want) } From e50ca75dc4e7f0dc4fec48aad89cb92d88137c07 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:30:26 +0800 Subject: [PATCH 10/18] refactor: consolidate manifest distribution flow --- cmd/doctor/doctor.go | 10 +- cmd/doctor/doctor_test.go | 13 +- cmd/update/manifest.go | 101 ++------- cmd/update/update.go | 9 +- cmd/update/update_test.go | 32 +-- extension/transport/registry_test.go | 10 +- extension/transport/types.go | 19 +- internal/distribution/config.go | 19 +- internal/distribution/errors.go | 65 ++++++ internal/distribution/errors_test.go | 40 ++++ .../install.go | 31 ++- .../install_test.go | 25 +-- internal/distribution/manifest.go | 7 +- internal/distribution/manifest_test.go | 2 +- internal/distribution/prepare.go | 18 +- internal/skillscheck/skip.go | 6 +- internal/update/update.go | 210 ++++-------------- internal/update/update_test.go | 4 +- internal/versioncheck/versioncheck.go | 135 +++++++++++ internal/versioncheck/versioncheck_test.go | 39 ++++ 20 files changed, 426 insertions(+), 369 deletions(-) create mode 100644 internal/distribution/errors.go create mode 100644 internal/distribution/errors_test.go rename internal/{distributioninstall => distribution}/install.go (90%) rename internal/{distributioninstall => distribution}/install_test.go (85%) create mode 100644 internal/versioncheck/versioncheck.go create mode 100644 internal/versioncheck/versioncheck_test.go diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index ef1013fa7d..1aba7ce52e 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -245,20 +245,20 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error { // Unlike the root-level async check, this does a synchronous fetch with timeout // and works regardless of build version (dev builds included). func checkCLIUpdate() []checkResult { - latest, err := fetchLatestForDoctor() + target, err := fetchLatestForDoctor() if err != nil { return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")} } current := build.Version - if update.IsUpdateAvailable(latest, current) { + if target.Available(current) { return []checkResult{warn("cli_update", - fmt.Sprintf("%s → %s available", current, latest), + fmt.Sprintf("%s → %s available", current, target.Version), "run: lark-cli update")} } - return []checkResult{pass("cli_update", latest+" (up to date)")} + return []checkResult{pass("cli_update", target.Version+" (up to date)")} } -var fetchLatestForDoctor = update.FetchLatest +var fetchLatestForDoctor = update.FetchTarget func finishDoctor(f *cmdutil.Factory, checks []checkResult) error { allOK := true diff --git a/cmd/doctor/doctor_test.go b/cmd/doctor/doctor_test.go index ffec1cdbcd..d58025e1d7 100644 --- a/cmd/doctor/doctor_test.go +++ b/cmd/doctor/doctor_test.go @@ -21,6 +21,7 @@ import ( "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/recovery" "github.com/larksuite/cli/internal/surface" + "github.com/larksuite/cli/internal/update" ) type doctorManifestProvider struct{} @@ -29,8 +30,8 @@ func (doctorManifestProvider) Name() string { return "doctor-manifest-test" } func (doctorManifestProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { return nil } -func (doctorManifestProvider) ResolveDistribution(context.Context) exttransport.DistributionConfig { - return exttransport.DistributionConfig{ManifestURL: "https://dist.example/manifest.json"} +func (doctorManifestProvider) ResolveManifestURL(context.Context) string { + return "https://dist.example/manifest.json" } func TestCheckCLIUpdateReportsDifferentOpaqueManifestTarget(t *testing.T) { @@ -38,7 +39,9 @@ func TestCheckCLIUpdateReportsDifferentOpaqueManifestTarget(t *testing.T) { previousFetch := fetchLatestForDoctor previousVersion := build.Version exttransport.Register(doctorManifestProvider{}) - fetchLatestForDoctor = func() (string, error) { return "older-channel", nil } + fetchLatestForDoctor = func() (update.Target, error) { + return update.Target{Version: "older-channel", Exact: true}, nil + } build.Version = "newer-channel" t.Cleanup(func() { exttransport.Register(previousProvider) @@ -139,9 +142,9 @@ func TestDoctorRunDoesNotFetchUpdateWhenCommandIsConcealed(t *testing.T) { t.Cleanup(func() { fetchLatestForDoctor = oldFetch }) fetches := 0 - fetchLatestForDoctor = func() (string, error) { + fetchLatestForDoctor = func() (update.Target, error) { fetches++ - return "9.9.9", nil + return update.Target{Version: "9.9.9"}, nil } plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{ surface.CommandUpdate: surface.CommandConcealed, diff --git a/cmd/update/manifest.go b/cmd/update/manifest.go index d90e323a99..4e629bfc20 100644 --- a/cmd/update/manifest.go +++ b/cmd/update/manifest.go @@ -5,45 +5,32 @@ package cmdupdate import ( "context" - "crypto/x509" - "errors" "fmt" - "net" - "os" - "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/distribution" - "github.com/larksuite/cli/internal/distributioninstall" "github.com/larksuite/cli/internal/output" ) -func runManifestUpdate(ctx context.Context, opts *UpdateOptions, source distribution.Source) error { +func runManifestUpdate(ctx context.Context, opts *UpdateOptions, manifestURL string) error { streams := opts.Factory.IOStreams current := currentVersion() - manifest, err := distribution.FetchManifest(ctx, source) + manifest, err := distribution.FetchManifest(ctx, manifestURL) if err != nil { return reportDistributionError(opts, "failed to load distribution manifest", err) } target := manifest.Version if opts.Check { - return reportManifestCheck(opts, current, target) + return reportManifestStatus(opts, current, target, true) } if !opts.Force && target == current { - return reportManifestCurrent(opts, current) + return reportManifestStatus(opts, current, target, false) } if !opts.JSON { fmt.Fprintf(streams.ErrOut, "Updating lark-cli %s %s %s from the configured distribution ...\n", current, symArrow(), target) } - prepared, err := distribution.PrepareUpdate(ctx, manifest) - if err != nil { - return reportDistributionError(opts, "failed to prepare distribution update", err) - } - defer prepared.Cleanup() - if err := distributioninstall.InstallPrepared(prepared, distributioninstall.InstallOptions{}); err != nil { - return reportError(opts, streams, "update_error", - errs.NewInternalError(errs.SubtypeUnknown, "failed to install distribution update: %s", err). - WithHint("Retry with `lark-cli update --force`.").WithCause(err)) + if err := distribution.Install(ctx, manifest, distribution.InstallOptions{}); err != nil { + return reportDistributionError(opts, "failed to install distribution update", err) } if opts.JSON { output.PrintJson(streams.Out, map[string]interface{}{ @@ -58,7 +45,7 @@ func runManifestUpdate(ctx context.Context, opts *UpdateOptions, source distribu return nil } -func reportManifestCheck(opts *UpdateOptions, current, target string) error { +func reportManifestStatus(opts *UpdateOptions, current, target string, check bool) error { streams := opts.Factory.IOStreams action := "already_up_to_date" message := fmt.Sprintf("lark-cli %s matches the configured target", current) @@ -67,11 +54,15 @@ func reportManifestCheck(opts *UpdateOptions, current, target string) error { message = fmt.Sprintf("lark-cli %s %s configured target %s", current, symArrow(), target) } if opts.JSON { - output.PrintJson(streams.Out, map[string]interface{}{ + result := map[string]interface{}{ "ok": true, "source": "manifest", "previous_version": current, "current_version": current, "target_version": target, - "action": action, "auto_update": true, "message": message, - }) + "action": action, "message": message, + } + if check { + result["auto_update"] = true + } + output.PrintJson(streams.Out, result) return nil } if current == target { @@ -82,73 +73,11 @@ func reportManifestCheck(opts *UpdateOptions, current, target string) error { return nil } -func reportManifestCurrent(opts *UpdateOptions, current string) error { - streams := opts.Factory.IOStreams - if opts.JSON { - output.PrintJson(streams.Out, map[string]interface{}{ - "ok": true, "source": "manifest", - "previous_version": current, "current_version": current, "target_version": current, - "action": "already_up_to_date", - "message": fmt.Sprintf("lark-cli %s matches the configured target", current), - }) - return nil - } - fmt.Fprintf(streams.ErrOut, "%s lark-cli %s matches the configured target\n", symOK(), current) - return nil -} - func reportDistributionError(opts *UpdateOptions, message string, err error) error { - typed := classifyDistributionError(message, err) + typed := distribution.ClassifyError(message, err) errType := "update_error" if problem, ok := errs.ProblemOf(typed); ok && problem.Category == errs.CategoryNetwork { errType = "network" } return reportError(opts, opts.Factory.IOStreams, errType, typed) } - -func classifyDistributionError(message string, err error) errs.TypedError { - var typed errs.TypedError - if errors.As(err, &typed) { - return typed - } - var pathErr *os.PathError - if errors.As(err, &pathErr) { - return errs.NewInternalError(errs.SubtypeFileIO, "%s", message).WithCause(err) - } - if status, ok := distribution.HTTPStatusCode(err); ok { - subtype := errs.SubtypeNetworkProtocol - retryable := false - switch { - case status == 408: - subtype, retryable = errs.SubtypeNetworkTimeout, true - case status >= 500: - subtype, retryable = errs.SubtypeNetworkServer, true - } - networkErr := errs.NewNetworkError(subtype, "%s", message).WithCode(status).WithCause(err) - if retryable { - networkErr.WithRetryable() - } - return networkErr - } - subtype := errs.SubtypeNetworkProtocol - retryable := false - var netErr net.Error - var dnsErr *net.DNSError - var authorityErr x509.UnknownAuthorityError - lower := strings.ToLower(err.Error()) - switch { - case errors.Is(err, context.DeadlineExceeded), errors.As(err, &netErr) && netErr.Timeout(): - subtype, retryable = errs.SubtypeNetworkTimeout, true - case errors.As(err, &authorityErr), strings.Contains(lower, "x509:"), strings.Contains(lower, "tls:"): - subtype = errs.SubtypeNetworkTLS - case errors.As(err, &dnsErr): - subtype, retryable = errs.SubtypeNetworkDNS, true - case errors.As(err, &netErr): - subtype, retryable = errs.SubtypeNetworkTransport, true - } - networkErr := errs.NewNetworkError(subtype, "%s", message).WithCause(err) - if retryable && !errors.Is(err, context.Canceled) { - networkErr.WithRetryable() - } - return networkErr -} diff --git a/cmd/update/update.go b/cmd/update/update.go index 3b82c14145..8bb801a6aa 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -33,7 +33,10 @@ const ( // Overridable for testing. var ( - fetchLatest = func() (string, error) { return update.FetchLatest() } + fetchLatest = func() (string, error) { + target, err := update.FetchTarget() + return target.Version, err + } currentVersion = func() string { return build.Version } currentOS = runtime.GOOS newUpdater = func() *selfupdate.Updater { return selfupdate.New() } @@ -150,7 +153,7 @@ func updateRunWithContext(ctx context.Context, opts *UpdateOptions) error { WithParam("--skills-layout"). WithHint("Remove --skills-layout when using --check.")) } - source, manifestMode, err := distribution.ResolveSource(ctx) + manifestURL, manifestMode, err := distribution.ResolveManifestURL(ctx) if err != nil { return reportError(opts, io, "configuration", errs.NewConfigError(errs.SubtypeInvalidConfig, "invalid distribution configuration: %s", err).WithCause(err)) @@ -162,7 +165,7 @@ func updateRunWithContext(ctx context.Context, opts *UpdateOptions) error { WithParam("--skills-layout")) } output.PendingNotice = nil - return runManifestUpdate(ctx, opts, source) + return runManifestUpdate(ctx, opts, manifestURL) } cur := currentVersion() updater := newUpdater() diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index c8aa052945..a3d562119b 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -10,7 +10,6 @@ import ( "encoding/json" "errors" "fmt" - "net" "net/http" "net/http/httptest" "os" @@ -51,8 +50,8 @@ func (p updateManifestProvider) Name() string { return "test-manifest" } func (p updateManifestProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { return nil } -func (p updateManifestProvider) ResolveDistribution(context.Context) exttransport.DistributionConfig { - return exttransport.DistributionConfig{ManifestURL: p.manifestURL} +func (p updateManifestProvider) ResolveManifestURL(context.Context) string { + return p.manifestURL } func TestManifestCheckAcceptsHTTPAndReportsOpaqueDowngradeTarget(t *testing.T) { @@ -89,33 +88,6 @@ func TestManifestCheckAcceptsHTTPAndReportsOpaqueDowngradeTarget(t *testing.T) { } } -func TestClassifyDistributionError(t *testing.T) { - tests := []struct { - name string - err error - category errs.Category - subtype errs.Subtype - retryable bool - }{ - {name: "timeout", err: context.DeadlineExceeded, category: errs.CategoryNetwork, subtype: errs.SubtypeNetworkTimeout, retryable: true}, - {name: "dns", err: &net.DNSError{Err: "lookup failed", Name: "dist.example"}, category: errs.CategoryNetwork, subtype: errs.SubtypeNetworkDNS, retryable: true}, - {name: "file IO", err: &os.PathError{Op: "mkdir", Path: "/tmp/config", Err: os.ErrPermission}, category: errs.CategoryInternal, subtype: errs.SubtypeFileIO}, - {name: "bad archive", err: errors.New("unsupported archive format"), category: errs.CategoryNetwork, subtype: errs.SubtypeNetworkProtocol}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := classifyDistributionError("distribution failed", tt.err) - problem, ok := errs.ProblemOf(got) - if !ok || problem.Category != tt.category || problem.Subtype != tt.subtype || problem.Retryable != tt.retryable { - t.Fatalf("problem = %#v, want category=%q subtype=%q retryable=%v", problem, tt.category, tt.subtype, tt.retryable) - } - if !errors.Is(got, tt.err) { - t.Fatalf("cause %v was not preserved", tt.err) - } - }) - } -} - func TestManifestArtifactProtocolFailureUsesNetworkTaxonomy(t *testing.T) { payload := []byte("not an archive") digest := fmt.Sprintf("sha256:%x", sha256.Sum256(payload)) diff --git a/extension/transport/registry_test.go b/extension/transport/registry_test.go index 30a614ea86..c7b3ded1fe 100644 --- a/extension/transport/registry_test.go +++ b/extension/transport/registry_test.go @@ -37,11 +37,11 @@ func (f stubURLRewriter) RewriteURL(rawURL string) string { return f(rawURL) } type stubDistributionProvider struct { stubProvider - config DistributionConfig + manifestURL string } -func (s *stubDistributionProvider) ResolveDistribution(context.Context) DistributionConfig { - return s.config +func (s *stubDistributionProvider) ResolveManifestURL(context.Context) string { + return s.manifestURL } func TestGetProvider_NilByDefault(t *testing.T) { @@ -123,14 +123,14 @@ func TestDistributionProviderIsOptional(t *testing.T) { t.Cleanup(func() { Register(previous) }) p := &stubDistributionProvider{ stubProvider: stubProvider{name: "distribution"}, - config: DistributionConfig{ManifestURL: "https://dist.example/manifest.json"}, + manifestURL: "https://dist.example/manifest.json", } Register(p) configured, ok := GetProvider().(DistributionProvider) if !ok { t.Fatal("registered provider does not implement DistributionProvider") } - if got := configured.ResolveDistribution(context.Background()).ManifestURL; got != p.config.ManifestURL { + if got := configured.ResolveManifestURL(context.Background()); got != p.manifestURL { t.Fatalf("ManifestURL = %q", got) } } diff --git a/extension/transport/types.go b/extension/transport/types.go index 099e29ff1d..dd7b198548 100644 --- a/extension/transport/types.go +++ b/extension/transport/types.go @@ -29,22 +29,17 @@ type URLRewriterProvider interface { ResolveURLRewriter(ctx context.Context) URLRewriter } -// DistributionConfig selects a fixed distribution manifest. Manifest and -// artifact URLs are final download addresses; the CLI does not pass them -// through URL rewriting or the request interceptor. HTTP is supported for -// trusted distribution networks; the provider is responsible for transport -// integrity when it does not use HTTPS. -type DistributionConfig struct { - ManifestURL string - _ struct{} -} - // DistributionProvider optionally supplies a distribution manifest in // addition to the existing request interceptor. Providers that do not -// implement this interface retain the package-manager update flow. +// implement this interface, or return an empty URL, retain the package-manager +// update flow. +// Manifest and artifact URLs are final download addresses; the CLI does not +// pass them through URL rewriting or the request interceptor. HTTP is supported +// for trusted distribution networks; the provider is responsible for transport +// integrity when it does not use HTTPS. type DistributionProvider interface { Provider - ResolveDistribution(ctx context.Context) DistributionConfig + ResolveManifestURL(ctx context.Context) string } // RequestClass describes the trust boundary of an outbound HTTP request. diff --git a/internal/distribution/config.go b/internal/distribution/config.go index 3a1a21bdd8..5bc50b3218 100644 --- a/internal/distribution/config.go +++ b/internal/distribution/config.go @@ -12,28 +12,23 @@ import ( exttransport "github.com/larksuite/cli/extension/transport" ) -// Source is the validated configured distribution source. -type Source struct { - ManifestURL string -} - -// ResolveSource returns the configured distribution source. The boolean is +// ResolveManifestURL returns the configured distribution manifest URL. The boolean is // false when the active transport provider does not opt into manifest-based // distribution or returns an empty URL. -func ResolveSource(ctx context.Context) (Source, bool, error) { +func ResolveManifestURL(ctx context.Context) (string, bool, error) { provider := exttransport.GetProvider() configured, ok := provider.(exttransport.DistributionProvider) if !ok { - return Source{}, false, nil + return "", false, nil } - raw := strings.TrimSpace(configured.ResolveDistribution(ctx).ManifestURL) + raw := strings.TrimSpace(configured.ResolveManifestURL(ctx)) if raw == "" { - return Source{}, false, nil + return "", false, nil } if err := validateDistributionURL(raw); err != nil { - return Source{}, false, fmt.Errorf("invalid distribution manifest URL: %w", err) + return "", false, fmt.Errorf("invalid distribution manifest URL: %w", err) } - return Source{ManifestURL: raw}, true, nil + return raw, true, nil } func validateDistributionURL(raw string) error { diff --git a/internal/distribution/errors.go b/internal/distribution/errors.go new file mode 100644 index 0000000000..e64d5abd5d --- /dev/null +++ b/internal/distribution/errors.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "context" + "crypto/x509" + "errors" + "net" + "os" + "strings" + + "github.com/larksuite/cli/errs" +) + +// ClassifyError maps distribution transport, protocol, and local file failures +// to the CLI error contract while preserving the original cause. +func ClassifyError(message string, err error) errs.TypedError { + var typed errs.TypedError + if errors.As(err, &typed) { + return typed + } + var pathErr *os.PathError + if errors.As(err, &pathErr) { + return errs.NewInternalError(errs.SubtypeFileIO, "%s", message).WithCause(err) + } + if status, ok := httpStatusCode(err); ok { + subtype := errs.SubtypeNetworkProtocol + retryable := false + switch { + case status == 408: + subtype, retryable = errs.SubtypeNetworkTimeout, true + case status >= 500: + subtype, retryable = errs.SubtypeNetworkServer, true + } + networkErr := errs.NewNetworkError(subtype, "%s", message).WithCode(status).WithCause(err) + if retryable { + networkErr.WithRetryable() + } + return networkErr + } + + subtype := errs.SubtypeNetworkProtocol + retryable := false + var netErr net.Error + var dnsErr *net.DNSError + var authorityErr x509.UnknownAuthorityError + lower := strings.ToLower(err.Error()) + switch { + case errors.Is(err, context.DeadlineExceeded), errors.As(err, &netErr) && netErr.Timeout(): + subtype, retryable = errs.SubtypeNetworkTimeout, true + case errors.As(err, &authorityErr), strings.Contains(lower, "x509:"), strings.Contains(lower, "tls:"): + subtype = errs.SubtypeNetworkTLS + case errors.As(err, &dnsErr): + subtype, retryable = errs.SubtypeNetworkDNS, true + case errors.As(err, &netErr): + subtype, retryable = errs.SubtypeNetworkTransport, true + } + networkErr := errs.NewNetworkError(subtype, "%s", message).WithCause(err) + if retryable && !errors.Is(err, context.Canceled) { + networkErr.WithRetryable() + } + return networkErr +} diff --git a/internal/distribution/errors_test.go b/internal/distribution/errors_test.go new file mode 100644 index 0000000000..e9feae9b67 --- /dev/null +++ b/internal/distribution/errors_test.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "context" + "errors" + "net" + "os" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestClassifyError(t *testing.T) { + for _, tt := range []struct { + name string + err error + category errs.Category + subtype errs.Subtype + retryable bool + }{ + {name: "timeout", err: context.DeadlineExceeded, category: errs.CategoryNetwork, subtype: errs.SubtypeNetworkTimeout, retryable: true}, + {name: "dns", err: &net.DNSError{Err: "lookup failed", Name: "dist.example"}, category: errs.CategoryNetwork, subtype: errs.SubtypeNetworkDNS, retryable: true}, + {name: "file IO", err: &os.PathError{Op: "mkdir", Path: "/tmp/config", Err: os.ErrPermission}, category: errs.CategoryInternal, subtype: errs.SubtypeFileIO}, + {name: "bad archive", err: errors.New("unsupported archive format"), category: errs.CategoryNetwork, subtype: errs.SubtypeNetworkProtocol}, + } { + t.Run(tt.name, func(t *testing.T) { + got := ClassifyError("distribution failed", tt.err) + problem, ok := errs.ProblemOf(got) + if !ok || problem.Category != tt.category || problem.Subtype != tt.subtype || problem.Retryable != tt.retryable { + t.Fatalf("problem = %#v, want category=%q subtype=%q retryable=%v", problem, tt.category, tt.subtype, tt.retryable) + } + if !errors.Is(got, tt.err) { + t.Fatalf("cause %v was not preserved", tt.err) + } + }) + } +} diff --git a/internal/distributioninstall/install.go b/internal/distribution/install.go similarity index 90% rename from internal/distributioninstall/install.go rename to internal/distribution/install.go index 3ae56f0cca..4f9ad0c862 100644 --- a/internal/distributioninstall/install.go +++ b/internal/distribution/install.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package distributioninstall +package distribution import ( "context" @@ -14,14 +14,14 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/distribution" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/vfs" ) const binaryVerifyTimeout = 10 * time.Second -// InstallOptions supplies destinations and test seams for a prepared update. +// InstallOptions supplies destinations and test seams for a distribution update. type InstallOptions struct { ExecutablePath string // SkillsDir overrides automatic Agent directory discovery when non-empty. @@ -29,9 +29,24 @@ type InstallOptions struct { VerifyBinary func(path, version string) error } -// InstallPrepared commits verified Skills and binary resources as one -// rollback-capable local transaction. The executable is committed last. -func InstallPrepared(prepared *distribution.PreparedUpdate, opts InstallOptions) error { +// Install downloads, verifies, and commits the configured Skills and binary +// resources as one rollback-capable local transaction. The executable is +// committed last. +func Install(ctx context.Context, manifest *Manifest, opts InstallOptions) error { + prepared, err := prepareUpdate(ctx, manifest) + if err != nil { + return ClassifyError("failed to prepare distribution update", err) + } + defer prepared.cleanup() + if err := installPrepared(prepared, opts); err != nil { + return errs.NewInternalError(errs.SubtypeUnknown, "failed to install distribution update: %s", err). + WithHint("Retry with `lark-cli update --force`."). + WithCause(err) + } + return nil +} + +func installPrepared(prepared *preparedUpdate, opts InstallOptions) error { if prepared == nil || prepared.Manifest == nil { return fmt.Errorf("prepared distribution update is required") } @@ -213,7 +228,7 @@ func matchesVersionOutput(output, version string) bool { return strings.TrimSpace(output) == "lark-cli version "+version } -func installSkills(prepared *distribution.PreparedUpdate, target string, previous *skillscheck.SkillsState) (func() error, func(), error) { +func installSkills(prepared *preparedUpdate, target string, previous *skillscheck.SkillsState) (func() error, func(), error) { parent := filepath.Dir(target) if err := vfs.MkdirAll(parent, 0o755); err != nil { return nil, nil, err @@ -282,7 +297,7 @@ func installSkills(prepared *distribution.PreparedUpdate, target string, previou return rollback, func() { _ = vfs.RemoveAll(stage); _ = vfs.RemoveAll(backup) }, nil } -func installSkillsToTargets(prepared *distribution.PreparedUpdate, targets []string, previous *skillscheck.SkillsState) (func() error, func(), error) { +func installSkillsToTargets(prepared *preparedUpdate, targets []string, previous *skillscheck.SkillsState) (func() error, func(), error) { rollbacks := make([]func() error, 0, len(targets)) finalizers := make([]func(), 0, len(targets)) rollbackAll := func() error { diff --git a/internal/distributioninstall/install_test.go b/internal/distribution/install_test.go similarity index 85% rename from internal/distributioninstall/install_test.go rename to internal/distribution/install_test.go index 2be3183e61..6a58f9d473 100644 --- a/internal/distributioninstall/install_test.go +++ b/internal/distribution/install_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package distributioninstall +package distribution import ( "errors" @@ -10,7 +10,6 @@ import ( "slices" "testing" - "github.com/larksuite/cli/internal/distribution" "github.com/larksuite/cli/internal/skillscheck" ) @@ -29,8 +28,8 @@ func TestInstallPreparedUpdatesManagedSkillsAndPreservesCustom(t *testing.T) { binary := filepath.Join(preparedRoot, "lark-cli") mustWrite(t, binary, "new") mustWrite(t, filepath.Join(preparedRoot, "skills", "new-managed", "SKILL.md"), "new") - prepared := &distribution.PreparedUpdate{Manifest: &distribution.Manifest{Version: "target"}, BinaryPath: binary, SkillsRoot: filepath.Join(preparedRoot, "skills"), SkillNames: []string{"new-managed"}} - if err := InstallPrepared(prepared, InstallOptions{ExecutablePath: executable, SkillsDir: skillsDir, VerifyBinary: func(path, version string) error { return nil }}); err != nil { + prepared := &preparedUpdate{Manifest: &Manifest{Version: "target"}, BinaryPath: binary, SkillsRoot: filepath.Join(preparedRoot, "skills"), SkillNames: []string{"new-managed"}} + if err := installPrepared(prepared, InstallOptions{ExecutablePath: executable, SkillsDir: skillsDir, VerifyBinary: func(path, version string) error { return nil }}); err != nil { t.Fatal(err) } assertFile(t, executable, "new") @@ -63,13 +62,13 @@ func TestInstallPreparedSyncsDetectedClaudeAndCodexSkillsDirs(t *testing.T) { binary := filepath.Join(preparedRoot, "lark-cli") mustWrite(t, binary, "new") mustWrite(t, filepath.Join(preparedRoot, "skills", "managed", "SKILL.md"), "new") - prepared := &distribution.PreparedUpdate{ - Manifest: &distribution.Manifest{Version: "target"}, + prepared := &preparedUpdate{ + Manifest: &Manifest{Version: "target"}, BinaryPath: binary, SkillsRoot: filepath.Join(preparedRoot, "skills"), SkillNames: []string{"managed"}, } - if err := InstallPrepared(prepared, InstallOptions{ExecutablePath: executable, VerifyBinary: func(path, version string) error { return nil }}); err != nil { + if err := installPrepared(prepared, InstallOptions{ExecutablePath: executable, VerifyBinary: func(path, version string) error { return nil }}); err != nil { t.Fatal(err) } for _, target := range []string{ @@ -110,8 +109,8 @@ func TestInstallSkillsToTargetsRollsBackEarlierTarget(t *testing.T) { mustWrite(t, blockedParent, "not a directory") preparedRoot := filepath.Join(root, "prepared") mustWrite(t, filepath.Join(preparedRoot, "skills", "managed", "SKILL.md"), "new") - prepared := &distribution.PreparedUpdate{ - Manifest: &distribution.Manifest{Version: "target"}, + prepared := &preparedUpdate{ + Manifest: &Manifest{Version: "target"}, SkillsRoot: filepath.Join(preparedRoot, "skills"), SkillNames: []string{"managed"}, } @@ -130,8 +129,8 @@ func TestInstallPreparedVerificationFailureDoesNotMutate(t *testing.T) { binary := filepath.Join(root, "prepared", "lark-cli") mustWrite(t, binary, "new") mustWrite(t, filepath.Join(root, "prepared", "skills", "managed", "SKILL.md"), "new") - prepared := &distribution.PreparedUpdate{Manifest: &distribution.Manifest{Version: "target"}, BinaryPath: binary, SkillsRoot: filepath.Join(root, "prepared", "skills"), SkillNames: []string{"managed"}} - err := InstallPrepared(prepared, InstallOptions{ExecutablePath: executable, SkillsDir: filepath.Join(root, "skills"), VerifyBinary: func(path, version string) error { return errors.New("bad binary") }}) + prepared := &preparedUpdate{Manifest: &Manifest{Version: "target"}, BinaryPath: binary, SkillsRoot: filepath.Join(root, "prepared", "skills"), SkillNames: []string{"managed"}} + err := installPrepared(prepared, InstallOptions{ExecutablePath: executable, SkillsDir: filepath.Join(root, "skills"), VerifyBinary: func(path, version string) error { return errors.New("bad binary") }}) if err == nil { t.Fatal("InstallPrepared succeeded") } @@ -160,8 +159,8 @@ func TestInstallPreparedBinaryCommitFailureRollsBackSkillsAndState(t *testing.T) binary := filepath.Join(root, "prepared", "lark-cli") mustWrite(t, binary, "new") mustWrite(t, filepath.Join(root, "prepared", "skills", "managed", "SKILL.md"), "new") - prepared := &distribution.PreparedUpdate{Manifest: &distribution.Manifest{Version: "target"}, BinaryPath: binary, SkillsRoot: filepath.Join(root, "prepared", "skills"), SkillNames: []string{"managed"}} - err := InstallPrepared(prepared, InstallOptions{ExecutablePath: missingExecutable, SkillsDir: skillsDir, VerifyBinary: func(path, version string) error { return nil }}) + prepared := &preparedUpdate{Manifest: &Manifest{Version: "target"}, BinaryPath: binary, SkillsRoot: filepath.Join(root, "prepared", "skills"), SkillNames: []string{"managed"}} + err := installPrepared(prepared, InstallOptions{ExecutablePath: missingExecutable, SkillsDir: skillsDir, VerifyBinary: func(path, version string) error { return nil }}) if err == nil { t.Fatal("InstallPrepared succeeded") } diff --git a/internal/distribution/manifest.go b/internal/distribution/manifest.go index 2616f3a5aa..a138476e55 100644 --- a/internal/distribution/manifest.go +++ b/internal/distribution/manifest.go @@ -66,10 +66,10 @@ func PlatformKey(goos, goarch string) string { return goos + "-" + goarch } func CurrentPlatformKey() string { return PlatformKey(runtime.GOOS, runtime.GOARCH) } // FetchManifest synchronously loads and validates the configured manifest. -func FetchManifest(ctx context.Context, source Source) (*Manifest, error) { +func FetchManifest(ctx context.Context, manifestURL string) (*Manifest, error) { ctx, cancel := context.WithTimeout(ctx, fetchTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, source.ManifestURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) if err != nil { return nil, fmt.Errorf("create manifest request: %w", err) } @@ -108,8 +108,7 @@ func newHTTPStatusError(operation string, statusCode int) error { return &httpStatusError{operation: operation, statusCode: statusCode} } -// HTTPStatusCode returns an upstream status preserved in a distribution error. -func HTTPStatusCode(err error) (int, bool) { +func httpStatusCode(err error) (int, bool) { var statusErr *httpStatusError if !errors.As(err, &statusErr) { return 0, false diff --git a/internal/distribution/manifest_test.go b/internal/distribution/manifest_test.go index b7a32d3bca..270289c5ff 100644 --- a/internal/distribution/manifest_test.go +++ b/internal/distribution/manifest_test.go @@ -59,7 +59,7 @@ func TestFetchManifestAppliesManifestDeadline(t *testing.T) { }, nil })} t.Cleanup(func() { DefaultClient = previousClient }) - if _, err := FetchManifest(context.Background(), Source{ManifestURL: "https://dist.example/manifest.json"}); err != nil { + if _, err := FetchManifest(context.Background(), "https://dist.example/manifest.json"); err != nil { t.Fatal(err) } } diff --git a/internal/distribution/prepare.go b/internal/distribution/prepare.go index 6e49fe3797..c5cda097de 100644 --- a/internal/distribution/prepare.go +++ b/internal/distribution/prepare.go @@ -14,9 +14,9 @@ import ( "github.com/larksuite/cli/internal/vfs" ) -// PreparedUpdate contains fully downloaded, checksum-verified, extracted -// resources. Call Cleanup when installation is not completed. -type PreparedUpdate struct { +// preparedUpdate contains fully downloaded, checksum-verified, extracted +// resources owned by one Install call. +type preparedUpdate struct { Manifest *Manifest BinaryPath string SkillsRoot string @@ -24,9 +24,9 @@ type PreparedUpdate struct { root string } -// PrepareUpdate downloads and validates every resource before installed state +// prepareUpdate downloads and validates every resource before installed state // is mutated. -func PrepareUpdate(ctx context.Context, manifest *Manifest) (*PreparedUpdate, error) { +func prepareUpdate(ctx context.Context, manifest *Manifest) (*preparedUpdate, error) { if manifest == nil { return nil, fmt.Errorf("distribution manifest is nil") } @@ -37,11 +37,11 @@ func PrepareUpdate(ctx context.Context, manifest *Manifest) (*PreparedUpdate, er if err != nil { return nil, err } - prepared := &PreparedUpdate{Manifest: manifest, root: root} + prepared := &preparedUpdate{Manifest: manifest, root: root} keep := false defer func() { if !keep { - prepared.Cleanup() + prepared.cleanup() } }() @@ -85,8 +85,8 @@ func PrepareUpdate(ctx context.Context, manifest *Manifest) (*PreparedUpdate, er return prepared, nil } -// Cleanup removes downloaded and extracted temporary resources. -func (p *PreparedUpdate) Cleanup() { +// cleanup removes downloaded and extracted temporary resources. +func (p *preparedUpdate) cleanup() { if p != nil && p.root != "" { _ = vfs.RemoveAll(p.root) } diff --git a/internal/skillscheck/skip.go b/internal/skillscheck/skip.go index b4da13d4b0..91eefe1236 100644 --- a/internal/skillscheck/skip.go +++ b/internal/skillscheck/skip.go @@ -6,7 +6,7 @@ package skillscheck import ( "os" - "github.com/larksuite/cli/internal/update" + "github.com/larksuite/cli/internal/versioncheck" ) // shouldSkip returns true when the skills check should be silently @@ -17,11 +17,11 @@ func shouldSkip(version string) bool { if os.Getenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER") != "" { return true } - if update.IsCIEnv() { + if versioncheck.IsCIEnv() { return true } if version == "DEV" || version == "dev" || version == "" { return true } - return !update.IsRelease(version) + return !versioncheck.IsRelease(version) } diff --git a/internal/update/update.go b/internal/update/update.go index 2a7733fdb5..06a2c0f900 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -13,9 +13,6 @@ import ( "net/http" "os" "path/filepath" - "regexp" - "strconv" - "strings" "sync/atomic" "time" @@ -23,6 +20,7 @@ import ( "github.com/larksuite/cli/internal/distribution" "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/versioncheck" "github.com/larksuite/cli/internal/vfs" ) @@ -82,7 +80,7 @@ type updateState struct { // CheckCached checks the local cache only (no network). Always fast. func CheckCached(currentVersion string) *UpdateInfo { - source, manifestMode, sourceErr := configuredSource() + manifestURL, manifestMode, sourceErr := distribution.ResolveManifestURL(context.Background()) if sourceErr != nil { return nil } @@ -94,7 +92,7 @@ func CheckCached(currentVersion string) *UpdateInfo { return nil } if manifestMode { - if state.Source != manifestSourceKey(source.ManifestURL) || state.LatestVersion == currentVersion { + if state.Source != manifestSourceKey(manifestURL) || state.LatestVersion == currentVersion { return nil } return &UpdateInfo{Current: currentVersion, Latest: state.LatestVersion, Source: "manifest"} @@ -108,7 +106,7 @@ func CheckCached(currentVersion string) *UpdateInfo { // RefreshCache fetches the configured target and updates the local cache. // No-op if the cache is still fresh (< 24h). Safe to call from a goroutine. func RefreshCache(currentVersion string) { - source, manifestMode, sourceErr := configuredSource() + manifestURL, manifestMode, sourceErr := distribution.ResolveManifestURL(context.Background()) if sourceErr != nil { return } @@ -118,21 +116,21 @@ func RefreshCache(currentVersion string) { state, _ := loadState() identityMatches := !manifestMode && state != nil && state.Source == "" if manifestMode { - identityMatches = state != nil && state.Source == manifestSourceKey(source.ManifestURL) + identityMatches = state != nil && state.Source == manifestSourceKey(manifestURL) } if identityMatches && time.Since(time.Unix(state.CheckedAt, 0)) < cacheTTL { return // cache is fresh } - latest, err := fetchLatestForMode(context.Background(), source, manifestMode) + target, err := fetchTarget(context.Background(), manifestURL, manifestMode) if err != nil { return } sourceKey := "" if manifestMode { - sourceKey = manifestSourceKey(source.ManifestURL) + sourceKey = manifestSourceKey(manifestURL) } _ = saveState(&updateState{ - LatestVersion: latest, + LatestVersion: target.Version, CheckedAt: time.Now().Unix(), Source: sourceKey, }) @@ -143,14 +141,6 @@ func manifestSourceKey(raw string) string { return "manifest:" + hex.EncodeToString(sum[:]) } -func configuredSource() (distribution.Source, bool, error) { - source, ok, err := distribution.ResolveSource(context.Background()) - if err != nil { - return distribution.Source{}, false, err - } - return source, ok, nil -} - func shouldSkipForMode(version string, manifestMode bool) bool { if manifestMode { if os.Getenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER") != "" || IsCIEnv() { @@ -184,15 +174,7 @@ func shouldSkip(version string) bool { // isRelease returns true for published versions: clean semver (1.0.0) // and npm prerelease (1.0.0-beta.1, 1.0.0-rc.1). // Returns false for git describe dev builds (v1.0.0-12-g9b933f1-dirty). -var gitDescribePattern = regexp.MustCompile(`-\d+-g[0-9a-f]{7,}`) - -func isRelease(version string) bool { - v := strings.TrimPrefix(version, "v") - if ParseVersion(v) == nil { - return false - } - return !gitDescribePattern.MatchString(v) -} +func isRelease(version string) bool { return versioncheck.IsRelease(version) } // IsRelease reports whether version looks like a clean published release // (semver "1.0.0", or npm prerelease "1.0.0-beta.1") and not a git-describe @@ -204,12 +186,7 @@ func IsRelease(version string) bool { return isRelease(version) } // is set. Exported for internal/skillscheck so its skip rules track the // same CI-suppression behavior as the update notifier. func IsCIEnv() bool { - for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} { - if os.Getenv(key) != "" { - return true - } - } - return false + return versioncheck.IsCIEnv() } // --- state file I/O --- @@ -242,38 +219,43 @@ func saveState(s *updateState) error { return validate.AtomicWrite(statePath(), data, 0644) } -// FetchLatest synchronously queries the active update source. It is intended -// for diagnostic commands such as doctor. -func FetchLatest() (string, error) { - source, ok, err := distribution.ResolveSource(context.Background()) +// Target describes the active source's desired CLI version. +type Target struct { + Version string + Exact bool +} + +// Available reports whether the target should be offered for current. +func (t Target) Available(current string) bool { + if t.Exact { + return t.Version != "" && t.Version != current + } + return IsNewer(t.Version, current) +} + +// FetchTarget synchronously queries the active update source. It is intended +// for explicit checks such as update and doctor. +func FetchTarget() (Target, error) { + manifestURL, manifestMode, err := distribution.ResolveManifestURL(context.Background()) if err != nil { - return "", err + return Target{}, err } - return fetchLatestForMode(context.Background(), source, ok) + return fetchTarget(context.Background(), manifestURL, manifestMode) } -func fetchLatestForMode(ctx context.Context, source distribution.Source, manifestMode bool) (string, error) { +func fetchTarget(ctx context.Context, manifestURL string, manifestMode bool) (Target, error) { if manifestMode { - manifest, err := distribution.FetchManifest(ctx, source) + manifest, err := distribution.FetchManifest(ctx, manifestURL) if err != nil { - return "", err + return Target{}, err } - return manifest.Version, nil + return Target{Version: manifest.Version, Exact: true}, nil } - return fetchLatestVersion() -} - -// IsUpdateAvailable applies the active distribution's comparison rule. -// Manifest versions are opaque targets, so any exact difference is actionable. -func IsUpdateAvailable(target, current string) bool { - _, manifestMode, err := configuredSource() + latest, err := fetchLatestVersion() if err != nil { - return false + return Target{}, err } - if manifestMode { - return target != "" && target != current - } - return IsNewer(target, current) + return Target{Version: latest}, nil } // --- npm registry --- @@ -317,125 +299,11 @@ func fetchLatestVersion() (string, error) { // is considered newer — an unparseable local version is assumed outdated. // When a cannot be parsed, returns false (can't confirm it's newer). func IsNewer(a, b string) bool { - ap := parseVersionDetail(a) - bp := parseVersionDetail(b) - if ap == nil { - return false // can't confirm remote is newer - } - if bp == nil { - return true // local version unparseable → assume outdated - } - for i := 0; i < 3; i++ { - if ap.core[i] > bp.core[i] { - return true - } - if ap.core[i] < bp.core[i] { - return false - } - } - return comparePrerelease(ap.prerelease, bp.prerelease) > 0 + return versioncheck.IsNewer(a, b) } // ParseVersion parses "X.Y.Z" (with optional "v" prefix and pre-release suffix) // into [major, minor, patch]. Returns nil on invalid input. func ParseVersion(v string) []int { - parsed := parseVersionDetail(v) - if parsed == nil { - return nil - } - return []int{parsed.core[0], parsed.core[1], parsed.core[2]} -} - -type parsedVersion struct { - core [3]int - prerelease string -} - -// validPrerelease matches semver pre-release identifiers (dot-separated). -// Each identifier is either: "0", a non-zero-leading numeric, or alphanumeric with at least one letter/hyphen. -// Rejects empty identifiers ("1.0.0-"), leading-zero numerics ("1.0.0-01"), etc. -var validPrerelease = regexp.MustCompile( - `^(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)` + - `(?:\.(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*$`) - -func parseVersionDetail(v string) *parsedVersion { - v = strings.TrimPrefix(v, "v") - if idx := strings.Index(v, "+"); idx >= 0 { - v = v[:idx] - } - prerelease := "" - if idx := strings.Index(v, "-"); idx >= 0 { - prerelease = v[idx+1:] - v = v[:idx] - if prerelease == "" || !validPrerelease.MatchString(prerelease) { - return nil - } - } - parts := strings.SplitN(v, ".", 3) - if len(parts) != 3 { - return nil - } - var nums [3]int - for i, p := range parts { - if len(p) > 1 && p[0] == '0' { - return nil // leading zero in core part (e.g. "01.0.0") - } - n, err := strconv.Atoi(p) - if err != nil { - return nil - } - nums[i] = n - } - return &parsedVersion{core: nums, prerelease: prerelease} -} - -func comparePrerelease(a, b string) int { - if a == "" && b == "" { - return 0 - } - if a == "" { - return 1 - } - if b == "" { - return -1 - } - ap := strings.Split(a, ".") - bp := strings.Split(b, ".") - for i := 0; i < len(ap) && i < len(bp); i++ { - cmp := comparePrereleaseIdentifier(ap[i], bp[i]) - if cmp != 0 { - return cmp - } - } - switch { - case len(ap) > len(bp): - return 1 - case len(ap) < len(bp): - return -1 - default: - return 0 - } -} - -func comparePrereleaseIdentifier(a, b string) int { - an, aErr := strconv.Atoi(a) - bn, bErr := strconv.Atoi(b) - aNumeric := aErr == nil - bNumeric := bErr == nil - switch { - case aNumeric && bNumeric: - if an > bn { - return 1 - } - if an < bn { - return -1 - } - return 0 - case aNumeric: - return -1 - case bNumeric: - return 1 - default: - return strings.Compare(a, b) - } + return versioncheck.Parse(v) } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 4bcbc949f8..e4d307b14f 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -36,8 +36,8 @@ func (p updateExternalProvider) ResolveInterceptor(context.Context) exttransport return p.interceptor } -func (p updateExternalProvider) ResolveDistribution(context.Context) exttransport.DistributionConfig { - return exttransport.DistributionConfig{ManifestURL: p.manifestURL} +func (p updateExternalProvider) ResolveManifestURL(context.Context) string { + return p.manifestURL } func (updateExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool { diff --git a/internal/versioncheck/versioncheck.go b/internal/versioncheck/versioncheck.go new file mode 100644 index 0000000000..ea9213bd47 --- /dev/null +++ b/internal/versioncheck/versioncheck.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package versioncheck owns version and environment predicates shared by +// update and Skills notification checks. +package versioncheck + +import ( + "os" + "regexp" + "strconv" + "strings" +) + +var gitDescribePattern = regexp.MustCompile(`-\d+-g[0-9a-f]{7,}`) + +var validPrerelease = regexp.MustCompile( + `^(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)` + + `(?:\.(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*$`) + +// IsRelease reports whether version is a clean published SemVer rather than a +// git-describe development build. +func IsRelease(version string) bool { + version = strings.TrimPrefix(version, "v") + return Parse(version) != nil && !gitDescribePattern.MatchString(version) +} + +// IsNewer reports whether a is a SemVer update over b. A valid remote version +// is considered newer than an unparseable local development version. +func IsNewer(a, b string) bool { + ap := parse(a) + bp := parse(b) + if ap == nil { + return false + } + if bp == nil { + return true + } + for i := range ap.core { + if ap.core[i] != bp.core[i] { + return ap.core[i] > bp.core[i] + } + } + return comparePrerelease(ap.prerelease, bp.prerelease) > 0 +} + +// Parse returns the major, minor, and patch components of a SemVer value. +func Parse(version string) []int { + parsed := parse(version) + if parsed == nil { + return nil + } + return []int{parsed.core[0], parsed.core[1], parsed.core[2]} +} + +type parsedVersion struct { + core [3]int + prerelease string +} + +func parse(version string) *parsedVersion { + version = strings.TrimPrefix(version, "v") + if idx := strings.Index(version, "+"); idx >= 0 { + version = version[:idx] + } + prerelease := "" + if idx := strings.Index(version, "-"); idx >= 0 { + prerelease = version[idx+1:] + version = version[:idx] + if prerelease == "" || !validPrerelease.MatchString(prerelease) { + return nil + } + } + parts := strings.SplitN(version, ".", 3) + if len(parts) != 3 { + return nil + } + var core [3]int + for i, part := range parts { + if len(part) > 1 && part[0] == '0' { + return nil + } + value, err := strconv.Atoi(part) + if err != nil { + return nil + } + core[i] = value + } + return &parsedVersion{core: core, prerelease: prerelease} +} + +func comparePrerelease(a, b string) int { + if a == "" && b == "" { + return 0 + } + if a == "" { + return 1 + } + if b == "" { + return -1 + } + aParts, bParts := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(aParts) && i < len(bParts); i++ { + if comparison := compareIdentifier(aParts[i], bParts[i]); comparison != 0 { + return comparison + } + } + return len(aParts) - len(bParts) +} + +func compareIdentifier(a, b string) int { + aNumber, aErr := strconv.Atoi(a) + bNumber, bErr := strconv.Atoi(b) + switch { + case aErr == nil && bErr == nil: + return aNumber - bNumber + case aErr == nil: + return -1 + case bErr == nil: + return 1 + default: + return strings.Compare(a, b) + } +} + +// IsCIEnv reports whether the process is running in a supported CI +// environment. +func IsCIEnv() bool { + for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} { + if os.Getenv(key) != "" { + return true + } + } + return false +} diff --git a/internal/versioncheck/versioncheck_test.go b/internal/versioncheck/versioncheck_test.go new file mode 100644 index 0000000000..165120a9da --- /dev/null +++ b/internal/versioncheck/versioncheck_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package versioncheck + +import "testing" + +func TestIsRelease(t *testing.T) { + for _, tt := range []struct { + version string + want bool + }{ + {"1.0.0", true}, + {"v1.0.0", true}, + {"1.0.0-beta.1", true}, + {"1.0.0+build.1", true}, + {"1.0.0-12-g9b933f1", false}, + {"1.0", false}, + {"DEV", false}, + } { + if got := IsRelease(tt.version); got != tt.want { + t.Errorf("IsRelease(%q) = %v, want %v", tt.version, got, tt.want) + } + } +} + +func TestIsCIEnv(t *testing.T) { + for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} { + t.Run(key, func(t *testing.T) { + for _, candidate := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} { + t.Setenv(candidate, "") + } + t.Setenv(key, "1") + if !IsCIEnv() { + t.Fatalf("IsCIEnv() = false with %s set", key) + } + }) + } +} From 34d74563ab678a2e603dc97ee90b41a86019f2d2 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:59:14 +0800 Subject: [PATCH 11/18] fix: tighten distribution extension boundaries --- cmd/update/manifest.go | 7 ++--- cmd/update/update.go | 7 ++--- extension/README.md | 13 ++++++++- extension/transport/registry.go | 12 ++++++-- extension/transport/types.go | 16 +++++++---- internal/distribution/config.go | 8 ++++-- internal/distribution/errors.go | 4 +-- internal/distribution/errors_test.go | 2 +- internal/distribution/install.go | 38 ++++++------------------- internal/distribution/manifest.go | 18 +++++++++++- internal/distribution/manifest_test.go | 12 ++++++++ internal/skillscheck/state.go | 39 ++++++++++++++++++++++++++ internal/skillscheck/state_test.go | 17 +++++++++++ internal/transport/extension.go | 8 ++++-- 14 files changed, 146 insertions(+), 55 deletions(-) diff --git a/cmd/update/manifest.go b/cmd/update/manifest.go index 4e629bfc20..f11586ffa7 100644 --- a/cmd/update/manifest.go +++ b/cmd/update/manifest.go @@ -17,7 +17,7 @@ func runManifestUpdate(ctx context.Context, opts *UpdateOptions, manifestURL str current := currentVersion() manifest, err := distribution.FetchManifest(ctx, manifestURL) if err != nil { - return reportDistributionError(opts, "failed to load distribution manifest", err) + return reportDistributionError(opts, err) } target := manifest.Version if opts.Check { @@ -30,7 +30,7 @@ func runManifestUpdate(ctx context.Context, opts *UpdateOptions, manifestURL str fmt.Fprintf(streams.ErrOut, "Updating lark-cli %s %s %s from the configured distribution ...\n", current, symArrow(), target) } if err := distribution.Install(ctx, manifest, distribution.InstallOptions{}); err != nil { - return reportDistributionError(opts, "failed to install distribution update", err) + return reportDistributionError(opts, err) } if opts.JSON { output.PrintJson(streams.Out, map[string]interface{}{ @@ -73,8 +73,7 @@ func reportManifestStatus(opts *UpdateOptions, current, target string, check boo return nil } -func reportDistributionError(opts *UpdateOptions, message string, err error) error { - typed := distribution.ClassifyError(message, err) +func reportDistributionError(opts *UpdateOptions, typed errs.TypedError) error { errType := "update_error" if problem, ok := errs.ProblemOf(typed); ok && problem.Category == errs.CategoryNetwork { errType = "network" diff --git a/cmd/update/update.go b/cmd/update/update.go index 8bb801a6aa..0e29abcd5a 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -153,10 +153,9 @@ func updateRunWithContext(ctx context.Context, opts *UpdateOptions) error { WithParam("--skills-layout"). WithHint("Remove --skills-layout when using --check.")) } - manifestURL, manifestMode, err := distribution.ResolveManifestURL(ctx) - if err != nil { - return reportError(opts, io, "configuration", - errs.NewConfigError(errs.SubtypeInvalidConfig, "invalid distribution configuration: %s", err).WithCause(err)) + manifestURL, manifestMode, configErr := distribution.ResolveManifestURL(ctx) + if configErr != nil { + return reportError(opts, io, "configuration", configErr) } if manifestMode { if strings.TrimSpace(opts.SkillsLayout) != "" { diff --git a/extension/README.md b/extension/README.md index be728db7c2..71df254890 100644 --- a/extension/README.md +++ b/extension/README.md @@ -7,7 +7,18 @@ Main extension points: | Package | Extension point | What it does | | ------- | --------------- | ------------ | | [`credential/`](./credential/) | **Credential** | Bring your own credential source: database, Vault, config center… | -| [`transport/`](./transport/) | **Transport** | Intercept every HTTP request: inject headers, rewrite targets, logging & monitoring | +| [`transport/`](./transport/) | **Transport** | Register one aggregate provider for request interception, URL rewriting, and an optional distribution manifest | | [`platform/`](./platform/) | **Restrict · Observer · Wrap · On** | Command allow/deny rules, audit hooks, onion-style middleware (approval gates, rate limiting), process lifecycle — see the [Plugin SDK README](./platform/README.md) | 📖 Full guide: [Embed lark-cli in your Agent](https://open.larksuite.com/document/mcp_open_tools/feishu-cli/embed-feishu-cli-in-agent) ([中文](https://open.larkoffice.com/document/mcp_open_tools/feishu-cli/embed-feishu-cli-in-agent)) + +The transport registry has one process-wide owner. Register the aggregate +provider during `init`, before constructing or executing the CLI. URL rewriting +runs before the request interceptor and also covers CLI-owned presentation URLs +and URLs passed to child processes. `ScopedProvider` limits only the request +interceptor. + +When `DistributionProvider` returns a manifest URL, that URL and the artifact +URLs inside the manifest are final download addresses. Distribution downloads +retain lark-cli's built-in proxy and custom-CA policy, but deliberately bypass +the registered URL rewriter and request interceptor. diff --git a/extension/transport/registry.go b/extension/transport/registry.go index d034b14b3d..fc45c0836c 100644 --- a/extension/transport/registry.go +++ b/extension/transport/registry.go @@ -10,9 +10,15 @@ var ( provider Provider ) -// Register registers a transport Provider. -// Later registrations override earlier ones. -// Typically called from init() via blank import. +// Register sets the process-wide transport Provider. +// +// lark-cli supports one aggregate Provider for request interception, URL +// rewriting, and distribution configuration. Integrations that need multiple +// capabilities compose them in that Provider and register it during init, +// before command construction or execution. Later registrations replace the +// earlier Provider for backward compatibility; changing the Provider while the +// CLI is running is unsupported because clients may already hold a resolved +// interceptor or URL rewriter. func Register(p Provider) { mu.Lock() defer mu.Unlock() diff --git a/extension/transport/types.go b/extension/transport/types.go index dd7b198548..d01c09d73a 100644 --- a/extension/transport/types.go +++ b/extension/transport/types.go @@ -22,8 +22,10 @@ type URLRewriter interface { } // URLRewriterProvider optionally supplies URL rewriting in addition to the -// existing request interceptor. Providers that do not implement this interface -// retain their existing behavior. +// existing request interceptor. ResolveURLRewriter must be a fast, local +// lookup; it may run while the CLI is constructing an HTTP client or rendering +// a non-network URL. Providers that do not implement this interface retain +// their existing behavior. type URLRewriterProvider interface { Provider ResolveURLRewriter(ctx context.Context) URLRewriter @@ -37,6 +39,8 @@ type URLRewriterProvider interface { // pass them through URL rewriting or the request interceptor. HTTP is supported // for trusted distribution networks; the provider is responsible for transport // integrity when it does not use HTTPS. +// ResolveManifestURL must be a fast, local lookup. Manifest fetching, parsing, +// and artifact installation are owned by the CLI. type DistributionProvider interface { Provider ResolveManifestURL(ctx context.Context) string @@ -55,9 +59,11 @@ const ( RequestClassExternal RequestClass = "external" ) -// ScopedProvider optionally limits a Provider to selected request classes. -// Providers that do not implement this interface retain the original -// behavior and apply to every request class. +// ScopedProvider optionally limits the request Interceptor to selected request +// classes. URL rewriting is intentionally not scoped: it also applies to +// presentation URLs and URLs passed to child processes, which have no request +// class. Providers that do not implement this interface retain the original +// interceptor behavior and apply to every request class. type ScopedProvider interface { Provider SupportsRequestClass(RequestClass) bool diff --git a/internal/distribution/config.go b/internal/distribution/config.go index 5bc50b3218..f0b1605810 100644 --- a/internal/distribution/config.go +++ b/internal/distribution/config.go @@ -9,13 +9,14 @@ import ( "net/url" "strings" + "github.com/larksuite/cli/errs" exttransport "github.com/larksuite/cli/extension/transport" ) // ResolveManifestURL returns the configured distribution manifest URL. The boolean is // false when the active transport provider does not opt into manifest-based // distribution or returns an empty URL. -func ResolveManifestURL(ctx context.Context) (string, bool, error) { +func ResolveManifestURL(ctx context.Context) (string, bool, errs.TypedError) { provider := exttransport.GetProvider() configured, ok := provider.(exttransport.DistributionProvider) if !ok { @@ -26,7 +27,10 @@ func ResolveManifestURL(ctx context.Context) (string, bool, error) { return "", false, nil } if err := validateDistributionURL(raw); err != nil { - return "", false, fmt.Errorf("invalid distribution manifest URL: %w", err) + return "", false, errs.NewConfigError( + errs.SubtypeInvalidConfig, + "invalid distribution manifest URL: %v", err, + ).WithCause(err) } return raw, true, nil } diff --git a/internal/distribution/errors.go b/internal/distribution/errors.go index e64d5abd5d..397512e44b 100644 --- a/internal/distribution/errors.go +++ b/internal/distribution/errors.go @@ -14,9 +14,9 @@ import ( "github.com/larksuite/cli/errs" ) -// ClassifyError maps distribution transport, protocol, and local file failures +// classifyError maps distribution transport, protocol, and local file failures // to the CLI error contract while preserving the original cause. -func ClassifyError(message string, err error) errs.TypedError { +func classifyError(message string, err error) errs.TypedError { var typed errs.TypedError if errors.As(err, &typed) { return typed diff --git a/internal/distribution/errors_test.go b/internal/distribution/errors_test.go index e9feae9b67..f771d7a240 100644 --- a/internal/distribution/errors_test.go +++ b/internal/distribution/errors_test.go @@ -27,7 +27,7 @@ func TestClassifyError(t *testing.T) { {name: "bad archive", err: errors.New("unsupported archive format"), category: errs.CategoryNetwork, subtype: errs.SubtypeNetworkProtocol}, } { t.Run(tt.name, func(t *testing.T) { - got := ClassifyError("distribution failed", tt.err) + got := classifyError("distribution failed", tt.err) problem, ok := errs.ProblemOf(got) if !ok || problem.Category != tt.category || problem.Subtype != tt.subtype || problem.Retryable != tt.retryable { t.Fatalf("problem = %#v, want category=%q subtype=%q retryable=%v", problem, tt.category, tt.subtype, tt.retryable) diff --git a/internal/distribution/install.go b/internal/distribution/install.go index 4f9ad0c862..ca2e029628 100644 --- a/internal/distribution/install.go +++ b/internal/distribution/install.go @@ -32,10 +32,10 @@ type InstallOptions struct { // Install downloads, verifies, and commits the configured Skills and binary // resources as one rollback-capable local transaction. The executable is // committed last. -func Install(ctx context.Context, manifest *Manifest, opts InstallOptions) error { +func Install(ctx context.Context, manifest *Manifest, opts InstallOptions) errs.TypedError { prepared, err := prepareUpdate(ctx, manifest) if err != nil { - return ClassifyError("failed to prepare distribution update", err) + return classifyError("failed to prepare distribution update", err) } defer prepared.cleanup() if err := installPrepared(prepared, opts); err != nil { @@ -93,15 +93,12 @@ func installPrepared(prepared *preparedUpdate, opts InstallOptions) error { return cause } - added := difference(prepared.SkillNames, officialSkills(previous)) - state := skillscheck.SkillsState{ - Version: prepared.Manifest.Version, - Layout: skillscheck.LayoutSeparate, - OfficialSkills: prepared.SkillNames, - UpdatedSkills: prepared.SkillNames, - AddedOfficialSkills: added, - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - } + state := skillscheck.NewCompleteState( + prepared.Manifest.Version, + skillscheck.LayoutSeparate, + prepared.SkillNames, + previous, + ) if err := skillscheck.WriteState(state); err != nil { return rollback(fmt.Errorf("write Skills state: %w", err)) } @@ -253,7 +250,7 @@ func installSkills(prepared *preparedUpdate, target string, previous *skillschec cleanup() return nil, nil, err } - managed := union(prepared.SkillNames, officialSkills(previous)) + managed := union(prepared.SkillNames, skillscheck.KnownOfficialSkills(previous)) movedOld := []string{} movedNew := []string{} rollback := func() error { @@ -396,13 +393,6 @@ func replaceBinary(staged, target string) (func(), error) { return func() { _ = vfs.Remove(backupPath) }, nil } -func officialSkills(state *skillscheck.SkillsState) []string { - if state == nil || state.OfficialSkillsUnknown { - return nil - } - return state.OfficialSkills -} - func union(a, b []string) []string { set := map[string]bool{} for _, values := range [][]string{a, b} { @@ -418,16 +408,6 @@ func union(a, b []string) []string { return result } -func difference(a, b []string) []string { - result := []string{} - for _, value := range a { - if !contains(b, value) { - result = append(result, value) - } - } - return result -} - func contains(values []string, value string) bool { for _, item := range values { if item == value { diff --git a/internal/distribution/manifest.go b/internal/distribution/manifest.go index a138476e55..c286b975b5 100644 --- a/internal/distribution/manifest.go +++ b/internal/distribution/manifest.go @@ -17,6 +17,9 @@ import ( "regexp" "runtime" "time" + + "github.com/larksuite/cli/errs" + internaltransport "github.com/larksuite/cli/internal/transport" ) const ( @@ -50,6 +53,9 @@ func httpClient() *http.Client { return DefaultClient } return &http.Client{ + // Distribution URLs bypass extension hooks, but they still use the CLI's + // built-in proxy, custom CA, and fail-closed transport policy. + Transport: internaltransport.Shared(), CheckRedirect: func(req *http.Request, _ []*http.Request) error { if req.URL.Scheme != "http" && req.URL.Scheme != "https" { return fmt.Errorf("distribution URL redirected to an unsupported scheme") @@ -66,7 +72,17 @@ func PlatformKey(goos, goarch string) string { return goos + "-" + goarch } func CurrentPlatformKey() string { return PlatformKey(runtime.GOOS, runtime.GOARCH) } // FetchManifest synchronously loads and validates the configured manifest. -func FetchManifest(ctx context.Context, manifestURL string) (*Manifest, error) { +// Failures are classified at this owner boundary before they reach commands, +// background checks, or diagnostics. +func FetchManifest(ctx context.Context, manifestURL string) (*Manifest, errs.TypedError) { + manifest, err := fetchManifest(ctx, manifestURL) + if err != nil { + return nil, classifyError("failed to load distribution manifest", err) + } + return manifest, nil +} + +func fetchManifest(ctx context.Context, manifestURL string) (*Manifest, error) { ctx, cancel := context.WithTimeout(ctx, fetchTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) diff --git a/internal/distribution/manifest_test.go b/internal/distribution/manifest_test.go index 270289c5ff..91e9c0da17 100644 --- a/internal/distribution/manifest_test.go +++ b/internal/distribution/manifest_test.go @@ -10,6 +10,8 @@ import ( "net/http" "strings" "testing" + + internaltransport "github.com/larksuite/cli/internal/transport" ) const testChecksum = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" @@ -22,6 +24,16 @@ func validManifestJSON(version string) string { return fmt.Sprintf(`{"schema":1,"version":%q,"artifacts":{"skills":{"url":"https://dist.example/skills.tar.gz","checksum":%q},"test-os":{"url":"https://dist.example/cli.tar.gz","checksum":%q}}}`, version, testChecksum, testChecksum) } +func TestDistributionClientUsesSharedBuiltInTransport(t *testing.T) { + previousClient := DefaultClient + DefaultClient = nil + t.Cleanup(func() { DefaultClient = previousClient }) + + if got, want := httpClient().Transport, internaltransport.Shared(); got != want { + t.Fatalf("distribution transport = %T, want shared transport %T", got, want) + } +} + func TestValidateDistributionURLAcceptsHTTPAndHTTPS(t *testing.T) { for _, raw := range []string{"http://dist.example/manifest.json", "https://dist.example/manifest.json"} { if err := validateDistributionURL(raw); err != nil { diff --git a/internal/skillscheck/state.go b/internal/skillscheck/state.go index e47fd11cec..6e425f06e3 100644 --- a/internal/skillscheck/state.go +++ b/internal/skillscheck/state.go @@ -9,6 +9,8 @@ import ( "fmt" "io/fs" "path/filepath" + "slices" + "time" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/validate" @@ -32,6 +34,43 @@ type SkillsState struct { UpdatedAt string `json:"updated_at"` } +// KnownOfficialSkills returns the previous managed Skill set when the state is +// authoritative. Callers receive a copy so installation planning cannot mutate +// the persisted state in memory. +func KnownOfficialSkills(state *SkillsState) []string { + if state == nil || state.OfficialSkillsUnknown { + return nil + } + return slices.Clone(state.OfficialSkills) +} + +// NewCompleteState builds state for a complete managed Skills replacement. +// Every supplied Skill is installed in this operation, and Skills that were not +// present in the previous authoritative state are recorded as newly added. +func NewCompleteState(version string, layout Layout, official []string, previous *SkillsState) SkillsState { + official = slices.Clone(official) + previousSet := make(map[string]bool) + for _, name := range KnownOfficialSkills(previous) { + previousSet[name] = true + } + added := make([]string, 0, len(official)) + for _, name := range official { + if !previousSet[name] { + added = append(added, name) + } + } + state := SkillsState{ + Version: version, + Layout: layout, + OfficialSkills: official, + UpdatedSkills: slices.Clone(official), + AddedOfficialSkills: added, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } + state.ensureNonNilSlices() + return state +} + func statePath() string { return filepath.Join(core.GetBaseConfigDir(), stateFile) } diff --git a/internal/skillscheck/state_test.go b/internal/skillscheck/state_test.go index 9a2b2a5dec..805de4f5b7 100644 --- a/internal/skillscheck/state_test.go +++ b/internal/skillscheck/state_test.go @@ -27,6 +27,23 @@ func TestReadState_Missing(t *testing.T) { } } +func TestNewCompleteStateOwnsManagedStateSemantics(t *testing.T) { + previous := &SkillsState{OfficialSkills: []string{"existing", "retired"}} + got := NewCompleteState("target", LayoutSeparate, []string{"existing", "new"}, previous) + + if got.Version != "target" || got.Layout != LayoutSeparate { + t.Fatalf("state identity = %#v", got) + } + if !reflect.DeepEqual(got.OfficialSkills, []string{"existing", "new"}) || + !reflect.DeepEqual(got.UpdatedSkills, []string{"existing", "new"}) || + !reflect.DeepEqual(got.AddedOfficialSkills, []string{"new"}) { + t.Fatalf("state Skills = %#v", got) + } + if got.SkippedDeletedSkills == nil || got.UpdatedAt == "" { + t.Fatalf("state completeness = %#v", got) + } +} + func TestReadState_Valid(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) diff --git a/internal/transport/extension.go b/internal/transport/extension.go index dbef2dd480..c64f5b6863 100644 --- a/internal/transport/extension.go +++ b/internal/transport/extension.go @@ -144,9 +144,11 @@ func WrapWithExtension(base http.RoundTripper) http.RoundTripper { return resolveExtension().wrap(base, "", false) } -// WrapWithExtensionForClass wraps base only when the registered provider -// supports class. Providers without the optional ScopedProvider interface keep -// their historical all-request behavior. +// WrapWithExtensionForClass applies URL rewriting and wraps base with the +// interceptor when the registered provider supports class. ScopedProvider only +// limits the interceptor; URL rewriting remains available for every class. +// Providers without ScopedProvider keep their historical all-request +// interceptor behavior. func WrapWithExtensionForClass(base http.RoundTripper, class exttransport.RequestClass) http.RoundTripper { return resolveExtension().wrap(base, class, true) } From 679a463080af0102b4ddd69ba41b1ef28b693b7b Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:17:17 +0800 Subject: [PATCH 12/18] refactor: streamline distribution update tests --- cmd/root_test.go | 26 +- cmd/update/update_test.go | 25 +- internal/distribution/binary.go | 92 ++++++ internal/distribution/destinations.go | 72 +++++ internal/distribution/install.go | 313 --------------------- internal/distribution/skills.go | 188 +++++++++++++ internal/selfupdate/updater_test.go | 47 +--- internal/testutil/urlrewrite/urlrewrite.go | 37 +++ shortcuts/apps/apps_init_test.go | 54 +--- shortcuts/mail/large_attachment_test.go | 32 +-- 10 files changed, 409 insertions(+), 477 deletions(-) create mode 100644 internal/distribution/binary.go create mode 100644 internal/distribution/destinations.go create mode 100644 internal/distribution/skills.go create mode 100644 internal/testutil/urlrewrite/urlrewrite.go diff --git a/cmd/root_test.go b/cmd/root_test.go index 1f8394b438..9d3c72b771 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -20,7 +20,6 @@ import ( cmdconfig "github.com/larksuite/cli/cmd/config" "github.com/larksuite/cli/cmd/schema" "github.com/larksuite/cli/errs" - exttransport "github.com/larksuite/cli/extension/transport" internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" @@ -30,6 +29,7 @@ import ( "github.com/larksuite/cli/internal/recovery" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/surface" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" ) // TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that @@ -92,28 +92,10 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) { } } -type rootURLRewriteProvider struct{} - -func (rootURLRewriteProvider) Name() string { return "test-url-rewrite" } - -func (rootURLRewriteProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { - return nil -} - -func (rootURLRewriteProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { - return rootURLRewriter{} -} - -type rootURLRewriter struct{} - -func (rootURLRewriter) RewriteURL(rawURL string) string { - return strings.Replace(rawURL, "github.com", "mirror.example.test", 1) -} - func TestBuildRewritesRootSkillsHelpURLAfterProviderRegistration(t *testing.T) { - previous := exttransport.GetProvider() - exttransport.Register(rootURLRewriteProvider{}) - t.Cleanup(func() { exttransport.Register(previous) }) + testurlrewrite.Register(t, func(rawURL string) string { + return strings.Replace(rawURL, "github.com", "mirror.example.test", 1) + }) _, root, _ := buildInternal(context.Background(), buildInvocationForTest(t), WithoutPlugins()) if got := root.UsageTemplate(); !strings.Contains(got, "https://mirror.example.test/larksuite/cli#agent-skills") { diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index a3d562119b..e6067eb642 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -28,22 +28,11 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/selfupdate" "github.com/larksuite/cli/internal/skillscheck" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" ) const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS" -type updateURLRewriteProvider struct{} - -func (updateURLRewriteProvider) Name() string { return "test-url-rewrite" } - -func (updateURLRewriteProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { - return nil -} - -func (updateURLRewriteProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { - return updateURLRewriter{} -} - type updateManifestProvider struct{ manifestURL string } func (p updateManifestProvider) Name() string { return "test-manifest" } @@ -129,12 +118,6 @@ func TestManifestArtifactProtocolFailureUsesNetworkTaxonomy(t *testing.T) { } } -type updateURLRewriter struct{} - -func (updateURLRewriter) RewriteURL(rawURL string) string { - return strings.Replace(rawURL, "github.com", "mirror.example.test", 1) -} - // newTestFactory creates a test factory with minimal config. func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { t.Helper() @@ -1017,9 +1000,9 @@ func TestReleaseURL(t *testing.T) { } func TestUpdateCheckRewritesPresentationURLs(t *testing.T) { - previous := exttransport.GetProvider() - exttransport.Register(updateURLRewriteProvider{}) - t.Cleanup(func() { exttransport.Register(previous) }) + testurlrewrite.Register(t, func(rawURL string) string { + return strings.Replace(rawURL, "github.com", "mirror.example.test", 1) + }) f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) diff --git a/internal/distribution/binary.go b/internal/distribution/binary.go new file mode 100644 index 0000000000..d472c926fd --- /dev/null +++ b/internal/distribution/binary.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/larksuite/cli/internal/vfs" +) + +const binaryVerifyTimeout = 10 * time.Second + +func stageBinary(source, executable string) (string, error) { + if err := vfs.MkdirAll(filepath.Dir(executable), 0o755); err != nil { + return "", err + } + in, err := vfs.Open(source) + if err != nil { + return "", err + } + defer in.Close() + out, err := vfs.CreateTemp(filepath.Dir(executable), ".lark-cli-new-*") + if err != nil { + return "", err + } + path := out.Name() + keep := false + defer func() { + _ = out.Close() + if !keep { + _ = vfs.Remove(path) + } + }() + if _, err := io.Copy(out, in); err != nil { + return "", err + } + if err := out.Chmod(0o755); err != nil { + return "", err + } + if err := out.Close(); err != nil { + return "", err + } + keep = true + return path, nil +} + +func verifyBinaryVersion(path, version string) error { + ctx, cancel := context.WithTimeout(context.Background(), binaryVerifyTimeout) + defer cancel() + output, err := exec.CommandContext(ctx, path, "--version").CombinedOutput() //nolint:gosec // path is the checksum-verified staged binary. + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("binary verification timed out after %s", binaryVerifyTimeout) + } + if err != nil { + return fmt.Errorf("run --version: %w", err) + } + if !matchesVersionOutput(string(output), version) { + return fmt.Errorf("binary reported %q, want version %q", strings.TrimSpace(string(output)), version) + } + return nil +} + +func matchesVersionOutput(output, version string) bool { + return strings.TrimSpace(output) == "lark-cli version "+version +} + +func replaceBinary(staged, target string) (func(), error) { + backupPath := target + ".old" + if _, err := vfs.Stat(backupPath); err == nil { + if err := vfs.Remove(backupPath); err != nil { + return nil, fmt.Errorf("remove stale binary backup: %w", err) + } + } else if !os.IsNotExist(err) { + return nil, err + } + if err := vfs.Rename(target, backupPath); err != nil { + return nil, err + } + if err := vfs.Rename(staged, target); err != nil { + _ = vfs.Rename(backupPath, target) + return nil, err + } + return func() { _ = vfs.Remove(backupPath) }, nil +} diff --git a/internal/distribution/destinations.go b/internal/distribution/destinations.go new file mode 100644 index 0000000000..63a03cb2d5 --- /dev/null +++ b/internal/distribution/destinations.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "os" + "path/filepath" + + "github.com/larksuite/cli/internal/vfs" +) + +func resolveInstallDestinations(opts InstallOptions) (string, []string, error) { + executable := opts.ExecutablePath + if executable == "" { + var err error + executable, err = vfs.Executable() + if err != nil { + return "", nil, err + } + executable, err = vfs.EvalSymlinks(executable) + if err != nil { + return "", nil, err + } + } + if opts.SkillsDir != "" { + return executable, []string{opts.SkillsDir}, nil + } + skillsDirs, err := discoverSkillsDirs() + if err != nil { + return "", nil, err + } + return executable, skillsDirs, nil +} + +// discoverSkillsDirs mirrors the destinations managed by `skills add -g` +// without invoking Node, which keeps manifest installation self-contained. +func discoverSkillsDirs() ([]string, error) { + home, err := vfs.UserHomeDir() + if err != nil { + return nil, err + } + dirs := []string{filepath.Join(home, ".agents", "skills")} + dirs = appendDetectedSkillsDir(dirs, os.Getenv("CLAUDE_CONFIG_DIR"), filepath.Join(home, ".claude")) + dirs = appendDetectedSkillsDir(dirs, os.Getenv("CODEX_HOME"), filepath.Join(home, ".codex")) + return uniquePaths(dirs), nil +} + +func appendDetectedSkillsDir(dirs []string, configuredRoot, defaultRoot string) []string { + root := configuredRoot + if root == "" { + root = defaultRoot + if info, err := vfs.Stat(root); err != nil || !info.IsDir() { + return dirs + } + } + return append(dirs, filepath.Join(root, "skills")) +} + +func uniquePaths(paths []string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(paths)) + for _, path := range paths { + path = filepath.Clean(path) + if seen[path] { + continue + } + seen[path] = true + result = append(result, path) + } + return result +} diff --git a/internal/distribution/install.go b/internal/distribution/install.go index ca2e029628..922cdc1fa1 100644 --- a/internal/distribution/install.go +++ b/internal/distribution/install.go @@ -6,21 +6,13 @@ package distribution import ( "context" "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "sort" "strings" - "time" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/vfs" ) -const binaryVerifyTimeout = 10 * time.Second - // InstallOptions supplies destinations and test seams for a distribution update. type InstallOptions struct { ExecutablePath string @@ -111,308 +103,3 @@ func installPrepared(prepared *preparedUpdate, opts InstallOptions) error { finalizeBinary() return nil } - -func resolveInstallDestinations(opts InstallOptions) (string, []string, error) { - executable := opts.ExecutablePath - if executable == "" { - var err error - executable, err = vfs.Executable() - if err != nil { - return "", nil, err - } - executable, err = vfs.EvalSymlinks(executable) - if err != nil { - return "", nil, err - } - } - if opts.SkillsDir != "" { - return executable, []string{opts.SkillsDir}, nil - } - skillsDirs, err := discoverSkillsDirs() - if err != nil { - return "", nil, err - } - return executable, skillsDirs, nil -} - -func discoverSkillsDirs() ([]string, error) { - home, err := vfs.UserHomeDir() - if err != nil { - return nil, err - } - dirs := []string{filepath.Join(home, ".agents", "skills")} - dirs = appendDetectedSkillsDir(dirs, os.Getenv("CLAUDE_CONFIG_DIR"), filepath.Join(home, ".claude")) - dirs = appendDetectedSkillsDir(dirs, os.Getenv("CODEX_HOME"), filepath.Join(home, ".codex")) - return uniquePaths(dirs), nil -} - -func appendDetectedSkillsDir(dirs []string, configuredRoot, defaultRoot string) []string { - root := configuredRoot - if root == "" { - root = defaultRoot - if info, err := vfs.Stat(root); err != nil || !info.IsDir() { - return dirs - } - } - return append(dirs, filepath.Join(root, "skills")) -} - -func uniquePaths(paths []string) []string { - seen := map[string]bool{} - result := make([]string, 0, len(paths)) - for _, path := range paths { - path = filepath.Clean(path) - if seen[path] { - continue - } - seen[path] = true - result = append(result, path) - } - return result -} - -func stageBinary(source, executable string) (string, error) { - if err := vfs.MkdirAll(filepath.Dir(executable), 0o755); err != nil { - return "", err - } - in, err := vfs.Open(source) - if err != nil { - return "", err - } - defer in.Close() - out, err := vfs.CreateTemp(filepath.Dir(executable), ".lark-cli-new-*") - if err != nil { - return "", err - } - path := out.Name() - keep := false - defer func() { - _ = out.Close() - if !keep { - _ = vfs.Remove(path) - } - }() - if _, err := io.Copy(out, in); err != nil { - return "", err - } - if err := out.Chmod(0o755); err != nil { - return "", err - } - if err := out.Close(); err != nil { - return "", err - } - keep = true - return path, nil -} - -func verifyBinaryVersion(path, version string) error { - ctx, cancel := context.WithTimeout(context.Background(), binaryVerifyTimeout) - defer cancel() - output, err := exec.CommandContext(ctx, path, "--version").CombinedOutput() //nolint:gosec // path is the checksum-verified staged binary. - if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("binary verification timed out after %s", binaryVerifyTimeout) - } - if err != nil { - return fmt.Errorf("run --version: %w", err) - } - if !matchesVersionOutput(string(output), version) { - return fmt.Errorf("binary reported %q, want version %q", strings.TrimSpace(string(output)), version) - } - return nil -} - -func matchesVersionOutput(output, version string) bool { - return strings.TrimSpace(output) == "lark-cli version "+version -} - -func installSkills(prepared *preparedUpdate, target string, previous *skillscheck.SkillsState) (func() error, func(), error) { - parent := filepath.Dir(target) - if err := vfs.MkdirAll(parent, 0o755); err != nil { - return nil, nil, err - } - stage, err := vfs.MkdirTemp(parent, ".lark-cli-skills-new-*") - if err != nil { - return nil, nil, err - } - backup, err := vfs.MkdirTemp(parent, ".lark-cli-skills-old-*") - if err != nil { - _ = vfs.RemoveAll(stage) - return nil, nil, err - } - cleanup := func() { _ = vfs.RemoveAll(stage); _ = vfs.RemoveAll(backup) } - for _, name := range prepared.SkillNames { - if err := copyTree(filepath.Join(prepared.SkillsRoot, name), filepath.Join(stage, name)); err != nil { - cleanup() - return nil, nil, err - } - } - if err := vfs.MkdirAll(target, 0o755); err != nil { - cleanup() - return nil, nil, err - } - managed := union(prepared.SkillNames, skillscheck.KnownOfficialSkills(previous)) - movedOld := []string{} - movedNew := []string{} - rollback := func() error { - var first error - for i := len(movedNew) - 1; i >= 0; i-- { - if err := vfs.RemoveAll(filepath.Join(target, movedNew[i])); err != nil && first == nil { - first = err - } - } - for i := len(movedOld) - 1; i >= 0; i-- { - name := movedOld[i] - if err := vfs.Rename(filepath.Join(backup, name), filepath.Join(target, name)); err != nil && first == nil { - first = err - } - } - return first - } - for _, name := range managed { - current := filepath.Join(target, name) - if _, err := vfs.Stat(current); err == nil { - if err := vfs.Rename(current, filepath.Join(backup, name)); err != nil { - _ = rollback() - cleanup() - return nil, nil, err - } - movedOld = append(movedOld, name) - } else if !os.IsNotExist(err) { - _ = rollback() - cleanup() - return nil, nil, err - } - if contains(prepared.SkillNames, name) { - if err := vfs.Rename(filepath.Join(stage, name), current); err != nil { - _ = rollback() - cleanup() - return nil, nil, err - } - movedNew = append(movedNew, name) - } - } - return rollback, func() { _ = vfs.RemoveAll(stage); _ = vfs.RemoveAll(backup) }, nil -} - -func installSkillsToTargets(prepared *preparedUpdate, targets []string, previous *skillscheck.SkillsState) (func() error, func(), error) { - rollbacks := make([]func() error, 0, len(targets)) - finalizers := make([]func(), 0, len(targets)) - rollbackAll := func() error { - var first error - for i := len(rollbacks) - 1; i >= 0; i-- { - if err := rollbacks[i](); err != nil && first == nil { - first = err - } - } - return first - } - finalizeAll := func() { - for _, finalize := range finalizers { - finalize() - } - } - for _, target := range targets { - rollback, finalize, err := installSkills(prepared, target, previous) - if err != nil { - _ = rollbackAll() - finalizeAll() - return nil, nil, fmt.Errorf("install Skills to %s: %w", target, err) - } - rollbacks = append(rollbacks, rollback) - finalizers = append(finalizers, finalize) - } - return rollbackAll, finalizeAll, nil -} - -func copyTree(source, destination string) error { - entries, err := vfs.ReadDir(source) - if err != nil { - return err - } - if err := vfs.MkdirAll(destination, 0o755); err != nil { - return err - } - for _, entry := range entries { - name := entry.Name() - src, dst := filepath.Join(source, name), filepath.Join(destination, name) - info, err := entry.Info() - if err != nil { - return err - } - if entry.IsDir() { - if err := copyTree(src, dst); err != nil { - return err - } - continue - } - in, err := vfs.Open(src) - if err != nil { - return err - } - perm := os.FileMode(0o644) - if info.Mode().Perm()&0o111 != 0 { - perm = 0o755 - } - out, err := vfs.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm) - if err != nil { - _ = in.Close() - return err - } - _, copyErr := io.Copy(out, in) - closeOutErr := out.Close() - closeInErr := in.Close() - if copyErr != nil { - return copyErr - } - if closeOutErr != nil { - return closeOutErr - } - if closeInErr != nil { - return closeInErr - } - } - return nil -} - -func replaceBinary(staged, target string) (func(), error) { - backupPath := target + ".old" - if _, err := vfs.Stat(backupPath); err == nil { - if err := vfs.Remove(backupPath); err != nil { - return nil, fmt.Errorf("remove stale binary backup: %w", err) - } - } else if !os.IsNotExist(err) { - return nil, err - } - if err := vfs.Rename(target, backupPath); err != nil { - return nil, err - } - if err := vfs.Rename(staged, target); err != nil { - _ = vfs.Rename(backupPath, target) - return nil, err - } - return func() { _ = vfs.Remove(backupPath) }, nil -} - -func union(a, b []string) []string { - set := map[string]bool{} - for _, values := range [][]string{a, b} { - for _, v := range values { - set[v] = true - } - } - result := make([]string, 0, len(set)) - for value := range set { - result = append(result, value) - } - sort.Strings(result) - return result -} - -func contains(values []string, value string) bool { - for _, item := range values { - if item == value { - return true - } - } - return false -} diff --git a/internal/distribution/skills.go b/internal/distribution/skills.go new file mode 100644 index 0000000000..82af1cd8f9 --- /dev/null +++ b/internal/distribution/skills.go @@ -0,0 +1,188 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "github.com/larksuite/cli/internal/skillscheck" + "github.com/larksuite/cli/internal/vfs" +) + +func installSkills(prepared *preparedUpdate, target string, previous *skillscheck.SkillsState) (func() error, func(), error) { + parent := filepath.Dir(target) + if err := vfs.MkdirAll(parent, 0o755); err != nil { + return nil, nil, err + } + stage, err := vfs.MkdirTemp(parent, ".lark-cli-skills-new-*") + if err != nil { + return nil, nil, err + } + backup, err := vfs.MkdirTemp(parent, ".lark-cli-skills-old-*") + if err != nil { + _ = vfs.RemoveAll(stage) + return nil, nil, err + } + cleanup := func() { _ = vfs.RemoveAll(stage); _ = vfs.RemoveAll(backup) } + for _, name := range prepared.SkillNames { + if err := copyTree(filepath.Join(prepared.SkillsRoot, name), filepath.Join(stage, name)); err != nil { + cleanup() + return nil, nil, err + } + } + if err := vfs.MkdirAll(target, 0o755); err != nil { + cleanup() + return nil, nil, err + } + managed := union(prepared.SkillNames, skillscheck.KnownOfficialSkills(previous)) + movedOld := []string{} + movedNew := []string{} + rollback := func() error { + var first error + for i := len(movedNew) - 1; i >= 0; i-- { + if err := vfs.RemoveAll(filepath.Join(target, movedNew[i])); err != nil && first == nil { + first = err + } + } + for i := len(movedOld) - 1; i >= 0; i-- { + name := movedOld[i] + if err := vfs.Rename(filepath.Join(backup, name), filepath.Join(target, name)); err != nil && first == nil { + first = err + } + } + return first + } + for _, name := range managed { + current := filepath.Join(target, name) + if _, err := vfs.Stat(current); err == nil { + if err := vfs.Rename(current, filepath.Join(backup, name)); err != nil { + _ = rollback() + cleanup() + return nil, nil, err + } + movedOld = append(movedOld, name) + } else if !os.IsNotExist(err) { + _ = rollback() + cleanup() + return nil, nil, err + } + if contains(prepared.SkillNames, name) { + if err := vfs.Rename(filepath.Join(stage, name), current); err != nil { + _ = rollback() + cleanup() + return nil, nil, err + } + movedNew = append(movedNew, name) + } + } + return rollback, cleanup, nil +} + +func installSkillsToTargets(prepared *preparedUpdate, targets []string, previous *skillscheck.SkillsState) (func() error, func(), error) { + rollbacks := make([]func() error, 0, len(targets)) + finalizers := make([]func(), 0, len(targets)) + rollbackAll := func() error { + var first error + for i := len(rollbacks) - 1; i >= 0; i-- { + if err := rollbacks[i](); err != nil && first == nil { + first = err + } + } + return first + } + finalizeAll := func() { + for _, finalize := range finalizers { + finalize() + } + } + for _, target := range targets { + rollback, finalize, err := installSkills(prepared, target, previous) + if err != nil { + _ = rollbackAll() + finalizeAll() + return nil, nil, fmt.Errorf("install Skills to %s: %w", target, err) + } + rollbacks = append(rollbacks, rollback) + finalizers = append(finalizers, finalize) + } + return rollbackAll, finalizeAll, nil +} + +func copyTree(source, destination string) error { + entries, err := vfs.ReadDir(source) + if err != nil { + return err + } + if err := vfs.MkdirAll(destination, 0o755); err != nil { + return err + } + for _, entry := range entries { + name := entry.Name() + src, dst := filepath.Join(source, name), filepath.Join(destination, name) + info, err := entry.Info() + if err != nil { + return err + } + if entry.IsDir() { + if err := copyTree(src, dst); err != nil { + return err + } + continue + } + in, err := vfs.Open(src) + if err != nil { + return err + } + perm := os.FileMode(0o644) + if info.Mode().Perm()&0o111 != 0 { + perm = 0o755 + } + out, err := vfs.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm) + if err != nil { + _ = in.Close() + return err + } + _, copyErr := io.Copy(out, in) + closeOutErr := out.Close() + closeInErr := in.Close() + if copyErr != nil { + return copyErr + } + if closeOutErr != nil { + return closeOutErr + } + if closeInErr != nil { + return closeInErr + } + } + return nil +} + +func union(a, b []string) []string { + set := map[string]bool{} + for _, values := range [][]string{a, b} { + for _, value := range values { + set[value] = true + } + } + result := make([]string, 0, len(set)) + for value := range set { + result = append(result, value) + } + sort.Strings(result) + return result +} + +func contains(values []string, value string) bool { + for _, item := range values { + if item == value { + return true + } + } + return false +} diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go index 4048a33518..966b29bef7 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -18,8 +18,8 @@ import ( "testing" "time" - exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/core" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" "github.com/larksuite/cli/internal/vfs" ) @@ -31,29 +31,6 @@ type executableTestFS struct { func (f executableTestFS) Executable() (string, error) { return f.exe, nil } -type skillsRewriteProvider struct { - rewriter exttransport.URLRewriter -} - -func (skillsRewriteProvider) Name() string { return "skills-rewrite" } - -func (skillsRewriteProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { return nil } - -func (p skillsRewriteProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { - return p.rewriter -} - -type skillsRewriteFunc func(string) string - -func (f skillsRewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } - -func withSkillsRewriteProvider(t *testing.T, rewriter exttransport.URLRewriter) { - t.Helper() - previous := exttransport.GetProvider() - exttransport.Register(skillsRewriteProvider{rewriter: rewriter}) - t.Cleanup(func() { exttransport.Register(previous) }) -} - // lookPathMock patches execLookPath within VerifyBinary for controlled testing. // Do not use t.Parallel() in tests that install this mock — it mutates a package-level var. type lookPathMock struct { @@ -275,12 +252,12 @@ func TestSkillsCommandsRewriteSourcesBeforeInvocation(t *testing.T) { t.Fatal(err) } t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - withSkillsRewriteProvider(t, skillsRewriteFunc(func(rawURL string) string { + testurlrewrite.Register(t, func(rawURL string) string { if strings.HasPrefix(rawURL, "https://open.feishu.cn") { return strings.Replace(rawURL, "https://open.feishu.cn", "http://mirror.example.test", 1) } return rawURL - })) + }) u := New() if result := u.StageSuite("https://open.feishu.cn/lark-cli/skills/regular", "."); result.Err != nil { @@ -308,24 +285,6 @@ func TestSkillsCommandsRewriteSourcesBeforeInvocation(t *testing.T) { } } -func TestSkillsCommandsPassRewrittenSourceVerbatim(t *testing.T) { - withSkillsRewriteProvider(t, skillsRewriteFunc(func(string) string { return "/relative" })) - var got []string - u := &Updater{SkillsCommandOverride: func(args ...string) *NpmResult { - got = append([]string(nil), args...) - return &NpmResult{} - }} - - result := u.InstallAllSkills("https://open.feishu.cn/lark-cli/skills/regular") - if result.Err != nil { - t.Fatalf("InstallAllSkills() error = %v", result.Err) - } - want := []string{"-y", "skills", "add", "/relative", "-g", "-y"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("args = %q, want %q", got, want) - } -} - func TestStageSuiteUsesProvidedWorkingDirectory(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses a POSIX shell script") diff --git a/internal/testutil/urlrewrite/urlrewrite.go b/internal/testutil/urlrewrite/urlrewrite.go new file mode 100644 index 0000000000..5e631f5989 --- /dev/null +++ b/internal/testutil/urlrewrite/urlrewrite.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package urlrewrite installs URL rewriters for tests. +package urlrewrite + +import ( + "context" + "testing" + + exttransport "github.com/larksuite/cli/extension/transport" +) + +type provider struct { + rewriter rewriteFunc +} + +func (provider) Name() string { return "test-url-rewrite" } + +func (provider) ResolveInterceptor(context.Context) exttransport.Interceptor { return nil } + +func (p provider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { + return p.rewriter +} + +type rewriteFunc func(string) string + +func (f rewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } + +// Register installs rewrite for the duration of the test. Tests using it must +// not run in parallel because the extension registry is process-wide. +func Register(t *testing.T, rewrite func(string) string) { + t.Helper() + previous := exttransport.GetProvider() + exttransport.Register(provider{rewriter: rewrite}) + t.Cleanup(func() { exttransport.Register(previous) }) +} diff --git a/shortcuts/apps/apps_init_test.go b/shortcuts/apps/apps_init_test.go index a9e350be93..fccf07419c 100644 --- a/shortcuts/apps/apps_init_test.go +++ b/shortcuts/apps/apps_init_test.go @@ -18,11 +18,11 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/errs" - exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/testutil/gitcmd" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" "github.com/larksuite/cli/shortcuts/common" ) @@ -157,29 +157,6 @@ func withFakeRunner(t *testing.T, f *fakeCommandRunner) { t.Cleanup(func() { initRunner = orig }) } -type appsRewriteProvider struct { - rewriter exttransport.URLRewriter -} - -func (appsRewriteProvider) Name() string { return "apps-rewrite" } - -func (appsRewriteProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { return nil } - -func (p appsRewriteProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { - return p.rewriter -} - -type appsRewriteFunc func(string) string - -func (f appsRewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } - -func withAppsRewriteProvider(t *testing.T, rewriter exttransport.URLRewriter) { - t.Helper() - previous := exttransport.GetProvider() - exttransport.Register(appsRewriteProvider{rewriter: rewriter}) - t.Cleanup(func() { exttransport.Register(previous) }) -} - func stubAppType(reg *httpmock.Registry, appID, appType string) { reg.Register(&httpmock.Stub{ Method: "GET", @@ -301,38 +278,19 @@ func TestRunScaffoldRewritesFixedRegistry(t *testing.T) { dir := t.TempDir() f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}} withFakeRunner(t, f) - withAppsRewriteProvider(t, appsRewriteFunc(func(rawURL string) string { + testurlrewrite.Register(t, func(rawURL string) string { if rawURL == npmRegistry { return "http://registry.example.test" } return rawURL - })) + }) if _, err := runScaffold(context.Background(), dir, "app_x", "", ""); err != nil { t.Fatalf("runScaffold() error = %v", err) } - for _, call := range f.calls { - if len(call) < 2 || call[1] != "npx" { - continue - } - if !containsAll(call, "--registry", "http://registry.example.test") { - t.Fatalf("npx call = %v, want rewritten registry", call) - } - } -} - -func TestRunScaffoldPassesRewrittenRegistryVerbatim(t *testing.T) { - f := &fakeCommandRunner{} - withFakeRunner(t, f) - withAppsRewriteProvider(t, appsRewriteFunc(func(string) string { return "/relative" })) - - _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "") - if err != nil { - t.Fatalf("runScaffold() error = %v", err) - } - call := findCall(f.calls, "npx", "-y") - if call == nil || !containsAll(call, "--registry", "/relative") { - t.Fatalf("npx call = %v, want verbatim rewritten registry", call) + npxCall := findCall(f.calls, "npx", "-y") + if npxCall == nil || !containsAll(npxCall, "--registry", "http://registry.example.test") { + t.Fatalf("npx call = %v, want rewritten registry", npxCall) } } diff --git a/shortcuts/mail/large_attachment_test.go b/shortcuts/mail/large_attachment_test.go index 4f2e714462..2bb026dda3 100644 --- a/shortcuts/mail/large_attachment_test.go +++ b/shortcuts/mail/large_attachment_test.go @@ -4,7 +4,6 @@ package mail import ( - "context" "encoding/base64" "encoding/json" "os" @@ -13,39 +12,14 @@ import ( "github.com/spf13/cobra" - exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/core" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" ) -type largeAttachmentRewriteProvider struct { - rewriter exttransport.URLRewriter -} - -func (largeAttachmentRewriteProvider) Name() string { return "mail-url-rewrite" } - -func (largeAttachmentRewriteProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { - return nil -} - -func (p largeAttachmentRewriteProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { - return p.rewriter -} - -type largeAttachmentRewriteFunc func(string) string - -func (f largeAttachmentRewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } - -func withLargeAttachmentURLRewriter(t *testing.T, rewriter exttransport.URLRewriter) { - t.Helper() - previous := exttransport.GetProvider() - exttransport.Register(largeAttachmentRewriteProvider{rewriter: rewriter}) - t.Cleanup(func() { exttransport.Register(previous) }) -} - func TestEstimateBase64EMLSize(t *testing.T) { // 3 bytes raw → 4 bytes base64 + ~200 overhead got := estimateBase64EMLSize(3) @@ -148,9 +122,9 @@ func TestBuildLargeAttachmentPreviewURL(t *testing.T) { } func TestBuildLargeAttachmentContentRewritesURLs(t *testing.T) { - withLargeAttachmentURLRewriter(t, largeAttachmentRewriteFunc(func(rawURL string) string { + testurlrewrite.Register(t, func(rawURL string) string { return strings.Replace(rawURL, "https://", "https://mirror.example/", 1) - })) + }) results := []largeAttachmentResult{{FileName: "report.pdf", FileSize: 1024, FileToken: "token"}} html := buildLargeAttachmentHTML(core.BrandFeishu, "en_us", results) From 7d2ba6eab2f15b96187519f1bc8ac1749daa5573 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:24:22 +0800 Subject: [PATCH 13/18] fix: harden distribution update recovery --- cmd/update/update_test.go | 1 + internal/distribution/archive.go | 32 +++++++++++-- internal/distribution/archive_test.go | 45 ++++++++++++++++--- internal/distribution/binary.go | 43 ++++++++++++++---- internal/distribution/install_test.go | 42 +++++++++++++---- .../config/allowlists/public-domains.txt | 2 + internal/skillscheck/state_test.go | 8 ++++ internal/update/update_test.go | 3 +- shortcuts/doc/docs_fetch_im_markdown_test.go | 15 +++++++ 9 files changed, 164 insertions(+), 27 deletions(-) diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index e6067eb642..c5156ce143 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -78,6 +78,7 @@ func TestManifestCheckAcceptsHTTPAndReportsOpaqueDowngradeTarget(t *testing.T) { } func TestManifestArtifactProtocolFailureUsesNetworkTaxonomy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) payload := []byte("not an archive") digest := fmt.Sprintf("sha256:%x", sha256.Sum256(payload)) var server *httptest.Server diff --git a/internal/distribution/archive.go b/internal/distribution/archive.go index 0e3086bccc..77c6649ed0 100644 --- a/internal/distribution/archive.go +++ b/internal/distribution/archive.go @@ -11,6 +11,7 @@ import ( "io" "os" "path/filepath" + "strings" "github.com/larksuite/cli/internal/vfs" ) @@ -76,7 +77,11 @@ func extractTarGzip(source io.Reader, destination string, maxBytes int64) error } switch header.Typeflag { case tar.TypeDir: - if err := vfs.MkdirAll(filepath.Join(destination, filepath.FromSlash(header.Name)), 0o755); err != nil { + target, err := archiveEntryPath(destination, header.Name) + if err != nil { + return err + } + if err := vfs.MkdirAll(target, 0o755); err != nil { return err } case tar.TypeReg, tar.TypeRegA: @@ -95,7 +100,11 @@ func extractZip(reader *zip.Reader, destination string, maxBytes int64) error { var total int64 for _, entry := range reader.File { if entry.FileInfo().IsDir() { - if err := vfs.MkdirAll(filepath.Join(destination, filepath.FromSlash(entry.Name)), 0o755); err != nil { + target, err := archiveEntryPath(destination, entry.Name) + if err != nil { + return err + } + if err := vfs.MkdirAll(target, 0o755); err != nil { return err } continue @@ -124,7 +133,10 @@ func extractZip(reader *zip.Reader, destination string, maxBytes int64) error { } func writeArchiveFile(root, name string, mode os.FileMode, source io.Reader) error { - target := filepath.Join(root, filepath.FromSlash(name)) + target, err := archiveEntryPath(root, name) + if err != nil { + return err + } if err := vfs.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } @@ -145,3 +157,17 @@ func writeArchiveFile(root, name string, mode os.FileMode, source io.Reader) err } return closeErr } + +func archiveEntryPath(root, name string) (string, error) { + localName := filepath.FromSlash(name) + if filepath.IsAbs(localName) || filepath.VolumeName(localName) != "" { + return "", fmt.Errorf("archive entry %q escapes the extraction root", name) + } + root = filepath.Clean(root) + target := filepath.Join(root, localName) + relative, err := filepath.Rel(root, target) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("archive entry %q escapes the extraction root", name) + } + return target, nil +} diff --git a/internal/distribution/archive_test.go b/internal/distribution/archive_test.go index 97627bc41d..7891d1da14 100644 --- a/internal/distribution/archive_test.go +++ b/internal/distribution/archive_test.go @@ -8,10 +8,13 @@ import ( "archive/zip" "bytes" "compress/gzip" - "os" + "errors" + "io/fs" "path/filepath" "strings" "testing" + + "github.com/larksuite/cli/internal/vfs" ) func TestExtractArchiveFormats(t *testing.T) { @@ -31,7 +34,7 @@ func TestExtractArchiveFormats(t *testing.T) { if err := extractArchive(archive, destination); err != nil { t.Fatal(err) } - got, err := os.ReadFile(filepath.Join(destination, "skill", "SKILL.md")) + got, err := vfs.ReadFile(filepath.Join(destination, "skill", "SKILL.md")) if err != nil { t.Fatal(err) } @@ -42,6 +45,28 @@ func TestExtractArchiveFormats(t *testing.T) { } } +func TestExtractArchiveRejectsEntriesOutsideDestination(t *testing.T) { + for _, tt := range []struct { + name string + build func(*testing.T, string, string) + }{ + {"tar.gz", writeTestTarGzipEntry}, + {"zip", writeTestZipEntry}, + } { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + archive := filepath.Join(root, "artifact") + tt.build(t, archive, "../escape") + if err := extractArchive(archive, filepath.Join(root, "out")); err == nil { + t.Fatal("extractArchive succeeded") + } + if _, err := vfs.Stat(filepath.Join(root, "escape")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("archive wrote outside destination: %v", err) + } + }) + } +} + func TestExtractArchiveRejectsExcessiveExpandedSize(t *testing.T) { for _, tt := range []struct { name string @@ -63,12 +88,16 @@ func TestExtractArchiveRejectsExcessiveExpandedSize(t *testing.T) { } func writeTestTarGzip(t *testing.T, path string) { + writeTestTarGzipEntry(t, path, "skill/SKILL.md") +} + +func writeTestTarGzipEntry(t *testing.T, path, name string) { t.Helper() var data bytes.Buffer gz := gzip.NewWriter(&data) tw := tar.NewWriter(gz) content := []byte("content") - if err := tw.WriteHeader(&tar.Header{Name: "skill/SKILL.md", Mode: 0o644, Size: int64(len(content))}); err != nil { + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))}); err != nil { t.Fatal(err) } if _, err := tw.Write(content); err != nil { @@ -80,16 +109,20 @@ func writeTestTarGzip(t *testing.T, path string) { if err := gz.Close(); err != nil { t.Fatal(err) } - if err := os.WriteFile(path, data.Bytes(), 0o600); err != nil { + if err := vfs.WriteFile(path, data.Bytes(), 0o600); err != nil { t.Fatal(err) } } func writeTestZip(t *testing.T, path string) { + writeTestZipEntry(t, path, "skill/SKILL.md") +} + +func writeTestZipEntry(t *testing.T, path, name string) { t.Helper() var data bytes.Buffer zw := zip.NewWriter(&data) - entry, err := zw.Create("skill/SKILL.md") + entry, err := zw.Create(name) if err != nil { t.Fatal(err) } @@ -99,7 +132,7 @@ func writeTestZip(t *testing.T, path string) { if err := zw.Close(); err != nil { t.Fatal(err) } - if err := os.WriteFile(path, data.Bytes(), 0o600); err != nil { + if err := vfs.WriteFile(path, data.Bytes(), 0o600); err != nil { t.Fatal(err) } } diff --git a/internal/distribution/binary.go b/internal/distribution/binary.go index d472c926fd..963489b800 100644 --- a/internal/distribution/binary.go +++ b/internal/distribution/binary.go @@ -5,9 +5,10 @@ package distribution import ( "context" + "errors" "fmt" "io" - "os" + "io/fs" "os/exec" "path/filepath" "strings" @@ -74,19 +75,45 @@ func matchesVersionOutput(output, version string) bool { func replaceBinary(staged, target string) (func(), error) { backupPath := target + ".old" - if _, err := vfs.Stat(backupPath); err == nil { + targetExists, err := pathExists(target) + if err != nil { + return nil, err + } + backupExists, err := pathExists(backupPath) + if err != nil { + return nil, err + } + if targetExists && backupExists { if err := vfs.Remove(backupPath); err != nil { return nil, fmt.Errorf("remove stale binary backup: %w", err) } - } else if !os.IsNotExist(err) { - return nil, err + backupExists = false } - if err := vfs.Rename(target, backupPath); err != nil { - return nil, err + if targetExists { + if err := vfs.Rename(target, backupPath); err != nil { + return nil, err + } + backupExists = true } if err := vfs.Rename(staged, target); err != nil { - _ = vfs.Rename(backupPath, target) + if targetExists { + _ = vfs.Rename(backupPath, target) + } return nil, err } - return func() { _ = vfs.Remove(backupPath) }, nil + return func() { + if backupExists { + _ = vfs.Remove(backupPath) + } + }, nil +} + +func pathExists(path string) (bool, error) { + if _, err := vfs.Stat(path); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, err + } + return true, nil } diff --git a/internal/distribution/install_test.go b/internal/distribution/install_test.go index 6a58f9d473..4efee6027c 100644 --- a/internal/distribution/install_test.go +++ b/internal/distribution/install_test.go @@ -5,12 +5,13 @@ package distribution import ( "errors" - "os" + "io/fs" "path/filepath" "slices" "testing" "github.com/larksuite/cli/internal/skillscheck" + "github.com/larksuite/cli/internal/vfs" ) func TestInstallPreparedUpdatesManagedSkillsAndPreservesCustom(t *testing.T) { @@ -35,7 +36,7 @@ func TestInstallPreparedUpdatesManagedSkillsAndPreservesCustom(t *testing.T) { assertFile(t, executable, "new") assertFile(t, filepath.Join(skillsDir, "new-managed", "SKILL.md"), "new") assertFile(t, filepath.Join(skillsDir, "custom", "SKILL.md"), "custom") - if _, err := os.Stat(filepath.Join(skillsDir, "old-managed")); !os.IsNotExist(err) { + if _, err := vfs.Stat(filepath.Join(skillsDir, "old-managed")); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("old managed Skill still exists: %v", err) } state, ok, err := skillscheck.ReadState() @@ -50,10 +51,10 @@ func TestInstallPreparedSyncsDetectedClaudeAndCodexSkillsDirs(t *testing.T) { t.Setenv("CLAUDE_CONFIG_DIR", "") t.Setenv("CODEX_HOME", "") t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")) - if err := os.MkdirAll(filepath.Join(root, ".claude"), 0o755); err != nil { + if err := vfs.MkdirAll(filepath.Join(root, ".claude"), 0o755); err != nil { t.Fatal(err) } - if err := os.MkdirAll(filepath.Join(root, ".codex"), 0o755); err != nil { + if err := vfs.MkdirAll(filepath.Join(root, ".codex"), 0o755); err != nil { t.Fatal(err) } executable := filepath.Join(root, "bin", "lark-cli") @@ -149,7 +150,9 @@ func TestMatchesVersionOutputSupportsOpaqueVersion(t *testing.T) { func TestInstallPreparedBinaryCommitFailureRollsBackSkillsAndState(t *testing.T) { root := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")) - missingExecutable := filepath.Join(root, "bin", "missing-lark-cli") + executable := filepath.Join(root, "bin", "lark-cli") + mustWrite(t, executable, "old") + mustWrite(t, filepath.Join(executable+".old", "block-removal"), "blocked") skillsDir := filepath.Join(root, "skills") mustWrite(t, filepath.Join(skillsDir, "managed", "SKILL.md"), "old") before := skillscheck.SkillsState{Version: "old", OfficialSkills: []string{"managed"}} @@ -160,7 +163,7 @@ func TestInstallPreparedBinaryCommitFailureRollsBackSkillsAndState(t *testing.T) mustWrite(t, binary, "new") mustWrite(t, filepath.Join(root, "prepared", "skills", "managed", "SKILL.md"), "new") prepared := &preparedUpdate{Manifest: &Manifest{Version: "target"}, BinaryPath: binary, SkillsRoot: filepath.Join(root, "prepared", "skills"), SkillNames: []string{"managed"}} - err := installPrepared(prepared, InstallOptions{ExecutablePath: missingExecutable, SkillsDir: skillsDir, VerifyBinary: func(path, version string) error { return nil }}) + err := installPrepared(prepared, InstallOptions{ExecutablePath: executable, SkillsDir: skillsDir, VerifyBinary: func(path, version string) error { return nil }}) if err == nil { t.Fatal("InstallPrepared succeeded") } @@ -169,21 +172,42 @@ func TestInstallPreparedBinaryCommitFailureRollsBackSkillsAndState(t *testing.T) if readErr != nil || !ok || state.Version != "old" { t.Fatalf("state after rollback = %#v, %v, %v", state, ok, readErr) } + assertFile(t, executable, "old") +} + +func TestReplaceBinaryRecoversWhenOnlyBackupExists(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "lark-cli") + backup := target + ".old" + staged := filepath.Join(root, "staged") + mustWrite(t, backup, "old") + mustWrite(t, staged, "new") + + cleanup, err := replaceBinary(staged, target) + if err != nil { + t.Fatal(err) + } + assertFile(t, target, "new") + assertFile(t, backup, "old") + cleanup() + if _, err := vfs.Stat(backup); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("backup still exists after cleanup: %v", err) + } } func mustWrite(t *testing.T, path, value string) { t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := vfs.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(path, []byte(value), 0o755); err != nil { + if err := vfs.WriteFile(path, []byte(value), 0o755); err != nil { t.Fatal(err) } } func assertFile(t *testing.T, path, want string) { t.Helper() - got, err := os.ReadFile(path) + got, err := vfs.ReadFile(path) if err != nil { t.Fatal(err) } diff --git a/internal/qualitygate/config/allowlists/public-domains.txt b/internal/qualitygate/config/allowlists/public-domains.txt index 3bf6e32c4a..d5fc8e09e1 100644 --- a/internal/qualitygate/config/allowlists/public-domains.txt +++ b/internal/qualitygate/config/allowlists/public-domains.txt @@ -4,6 +4,7 @@ accounts.larksuite.com applink.feishu.cn applink.larksuite.com ark.ap-southeast.bytepluses.com +docs.npmjs.com github.com larkoffice.com lf-larkemail.bytetos.com @@ -11,6 +12,7 @@ mcp.feishu.cn mcp.larksuite.com open.feishu.cn open.larksuite.com +pnpm.io registry.npmjs.org registry.npmmirror.com sf16-sg.tiktokcdn.com diff --git a/internal/skillscheck/state_test.go b/internal/skillscheck/state_test.go index 805de4f5b7..7e9cd7b7eb 100644 --- a/internal/skillscheck/state_test.go +++ b/internal/skillscheck/state_test.go @@ -42,6 +42,14 @@ func TestNewCompleteStateOwnsManagedStateSemantics(t *testing.T) { if got.SkippedDeletedSkills == nil || got.UpdatedAt == "" { t.Fatalf("state completeness = %#v", got) } + if known := KnownOfficialSkills(&SkillsState{OfficialSkillsUnknown: true, OfficialSkills: []string{"existing"}}); known != nil { + t.Fatalf("unknown official Skills = %#v, want nil", known) + } + known := KnownOfficialSkills(&got) + known[0] = "mutated" + if got.OfficialSkills[0] == "mutated" { + t.Fatal("KnownOfficialSkills returned state-owned storage") + } } func TestReadState_Valid(t *testing.T) { diff --git a/internal/update/update_test.go b/internal/update/update_test.go index e4d307b14f..736d894801 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -18,6 +18,7 @@ import ( exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/distribution" + "github.com/larksuite/cli/internal/vfs" ) // roundTripFunc adapts a function to http.RoundTripper. @@ -74,7 +75,7 @@ func TestManifestCacheUsesExactTargetAndSourceIdentity(t *testing.T) { }) RefreshCache("new-current") - stateBytes, err := os.ReadFile(statePath()) + stateBytes, err := vfs.ReadFile(statePath()) if err != nil { t.Fatal(err) } diff --git a/shortcuts/doc/docs_fetch_im_markdown_test.go b/shortcuts/doc/docs_fetch_im_markdown_test.go index 3c8e7de59c..4afd2ecacd 100644 --- a/shortcuts/doc/docs_fetch_im_markdown_test.go +++ b/shortcuts/doc/docs_fetch_im_markdown_test.go @@ -7,6 +7,8 @@ import ( "reflect" "strings" "testing" + + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" ) func TestApplyFetchIMMarkdown(t *testing.T) { @@ -70,6 +72,19 @@ func TestApplyFetchIMMarkdown(t *testing.T) { } } +func TestNewIMMarkdownContextRewritesFallbackURL(t *testing.T) { + testurlrewrite.Register(t, func(raw string) string { + if raw == "https://larkoffice.com" { + return "https://tenant.example.com/base" + } + return raw + }) + + if got := newIMMarkdownContext("doc_token").baseURL; got != "https://tenant.example.com/base" { + t.Fatalf("baseURL = %q", got) + } +} + func TestConvertToIMMarkdownTitle(t *testing.T) { t.Parallel() From bcce86559e171ee17ae4d3f225c34e6c422749f7 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:33:17 +0800 Subject: [PATCH 14/18] test: use approved URL rewrite fixture --- shortcuts/doc/docs_fetch_im_markdown_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shortcuts/doc/docs_fetch_im_markdown_test.go b/shortcuts/doc/docs_fetch_im_markdown_test.go index 4afd2ecacd..928393e1bb 100644 --- a/shortcuts/doc/docs_fetch_im_markdown_test.go +++ b/shortcuts/doc/docs_fetch_im_markdown_test.go @@ -75,12 +75,12 @@ func TestApplyFetchIMMarkdown(t *testing.T) { func TestNewIMMarkdownContextRewritesFallbackURL(t *testing.T) { testurlrewrite.Register(t, func(raw string) string { if raw == "https://larkoffice.com" { - return "https://tenant.example.com/base" + return "https://example.larkoffice.com/base" } return raw }) - if got := newIMMarkdownContext("doc_token").baseURL; got != "https://tenant.example.com/base" { + if got := newIMMarkdownContext("doc_token").baseURL; got != "https://example.larkoffice.com/base" { t.Fatalf("baseURL = %q", got) } } From 5bf546f970a94b82169a5b7b6a1a8d56b9f06439 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:04:13 +0800 Subject: [PATCH 15/18] fix: allow manifest extension fields --- internal/distribution/manifest.go | 5 +++-- internal/distribution/manifest_test.go | 22 +++++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/internal/distribution/manifest.go b/internal/distribution/manifest.go index c286b975b5..b9c1d603d1 100644 --- a/internal/distribution/manifest.go +++ b/internal/distribution/manifest.go @@ -37,7 +37,9 @@ type Artifact struct { Checksum string `json:"checksum"` } -// Manifest is schema 1 of the distribution protocol. +// Manifest is schema 1 of the distribution protocol. Unknown JSON fields are +// ignored so producers can attach metadata without breaking older CLIs; the +// required fields and artifacts are still validated before use. type Manifest struct { Schema int `json:"schema"` Version string `json:"version"` @@ -142,7 +144,6 @@ func redactRequestError(err error) error { func parseManifest(data []byte, platformKey string) (*Manifest, error) { decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() var manifest Manifest if err := decoder.Decode(&manifest); err != nil { return nil, fmt.Errorf("invalid distribution manifest: %w", err) diff --git a/internal/distribution/manifest_test.go b/internal/distribution/manifest_test.go index 91e9c0da17..8e8e717123 100644 --- a/internal/distribution/manifest_test.go +++ b/internal/distribution/manifest_test.go @@ -90,12 +90,32 @@ func TestParseManifestIgnoresArtifactsForOtherPlatforms(t *testing.T) { } } +func TestParseManifestAllowsExtensionFields(t *testing.T) { + input := strings.Replace( + validManifestJSON("1"), + `"schema":1`, + `"schema":1,"environment":"customer-a"`, + 1, + ) + input = strings.Replace( + input, + `"url":"https://dist.example/skills.tar.gz"`, + `"url":"https://dist.example/skills.tar.gz","channel":"stable"`, + 1, + ) + if _, err := parseManifest([]byte(input), "test-os"); err != nil { + t.Fatal(err) + } +} + func TestParseManifestRejectsInvalidContracts(t *testing.T) { tests := []struct{ name, input, contains string }{ - {"unknown field", strings.Replace(validManifestJSON("1"), `"schema":1`, `"schema":1,"extra":true`, 1), "unknown field"}, + {"schema", strings.Replace(validManifestJSON("1"), `"schema":1`, `"schema":2`, 1), "unsupported distribution manifest schema"}, + {"missing version", strings.Replace(validManifestJSON("1"), `"version":"1",`, "", 1), "version must be"}, {"unsupported scheme", strings.Replace(validManifestJSON("1"), "https://dist.example/skills", "file:///tmp/skills", 1), "HTTP or HTTPS"}, {"checksum", strings.Replace(validManifestJSON("1"), testChecksum, "sha256:ABC", 1), "checksum"}, {"missing skills", strings.Replace(validManifestJSON("1"), `"skills"`, `"other"`, 1), "missing required"}, + {"missing platform", strings.Replace(validManifestJSON("1"), `"test-os"`, `"other-os"`, 1), "missing required"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 547f70b889d932614a7faa8d881f35d11cbb8faf Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:33:47 +0800 Subject: [PATCH 16/18] test: cover distribution update paths --- internal/distribution/prepare_test.go | 101 +++++++++++++++++++++ internal/versioncheck/versioncheck_test.go | 48 ++++++++++ 2 files changed, 149 insertions(+) diff --git a/internal/distribution/prepare_test.go b/internal/distribution/prepare_test.go index 0715c97399..e6605caaf6 100644 --- a/internal/distribution/prepare_test.go +++ b/internal/distribution/prepare_test.go @@ -4,12 +4,90 @@ package distribution import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "fmt" + "io" + "net/http" "os" "path/filepath" "reflect" + "runtime" "testing" + + "github.com/larksuite/cli/internal/vfs" ) +func TestInstallDownloadsAndCommitsManifestArtifacts(t *testing.T) { + root := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")) + executable := filepath.Join(root, "bin", "lark-cli") + mustWrite(t, executable, "old binary") + + executableName := "lark-cli" + if runtime.GOOS == "windows" { + executableName += ".exe" + } + binaryArchive := buildTestZip(t, map[string]string{executableName: "new binary"}) + skillsArchive := buildTestZip(t, map[string]string{ + "README.md": "bundle metadata", + "lark-alpha/SKILL.md": "alpha", + "lark-beta/SKILL.md": "beta", + }) + payloads := map[string][]byte{ + "/cli.zip": binaryArchive, + "/skills.zip": skillsArchive, + } + previousClient := DefaultClient + DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + payload, ok := payloads[req.URL.Path] + if !ok { + return &http.Response{StatusCode: http.StatusNotFound, Body: http.NoBody, Header: make(http.Header)}, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(payload)), + ContentLength: int64(len(payload)), + Header: make(http.Header), + }, nil + })} + t.Cleanup(func() { DefaultClient = previousClient }) + + manifest := &Manifest{ + Version: "release-channel-7", + Artifacts: map[string]Artifact{ + CurrentPlatformKey(): {URL: "https://dist.example/cli.zip", Checksum: checksumFor(binaryArchive)}, + SkillsKey: {URL: "https://dist.example/skills.zip", Checksum: checksumFor(skillsArchive)}, + }, + } + skillsDir := filepath.Join(root, "skills") + err := Install(context.Background(), manifest, InstallOptions{ + ExecutablePath: executable, + SkillsDir: skillsDir, + VerifyBinary: func(path, version string) error { + if version != manifest.Version { + return fmt.Errorf("version = %q", version) + } + content, err := vfs.ReadFile(path) + if err != nil { + return err + } + if string(content) != "new binary" { + return fmt.Errorf("binary = %q", content) + } + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + assertFile(t, executable, "new binary") + assertFile(t, filepath.Join(skillsDir, "lark-alpha", "SKILL.md"), "alpha") + assertFile(t, filepath.Join(skillsDir, "lark-beta", "SKILL.md"), "beta") +} + func TestListSkillsIgnoresRootFiles(t *testing.T) { root := t.TempDir() if err := os.Mkdir(filepath.Join(root, "lark-example"), 0o755); err != nil { @@ -26,3 +104,26 @@ func TestListSkillsIgnoresRootFiles(t *testing.T) { t.Fatalf("names = %v, want %v", names, want) } } + +func buildTestZip(t *testing.T, files map[string]string) []byte { + t.Helper() + var data bytes.Buffer + writer := zip.NewWriter(&data) + for name, content := range files { + entry, err := writer.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return data.Bytes() +} + +func checksumFor(data []byte) string { + return fmt.Sprintf("sha256:%x", sha256.Sum256(data)) +} diff --git a/internal/versioncheck/versioncheck_test.go b/internal/versioncheck/versioncheck_test.go index 165120a9da..43340170cb 100644 --- a/internal/versioncheck/versioncheck_test.go +++ b/internal/versioncheck/versioncheck_test.go @@ -24,6 +24,54 @@ func TestIsRelease(t *testing.T) { } } +func TestIsNewerFollowsSemVerPrecedence(t *testing.T) { + ordered := []string{ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + "1.0.1", + "1.1.0", + "2.0.0", + } + for i := 1; i < len(ordered); i++ { + older, newer := ordered[i-1], ordered[i] + t.Run(older+"_to_"+newer, func(t *testing.T) { + if !IsNewer(newer, older) { + t.Fatalf("IsNewer(%q, %q) = false", newer, older) + } + if IsNewer(older, newer) { + t.Fatalf("IsNewer(%q, %q) = true", older, newer) + } + }) + } +} + +func TestIsNewerHandlesVersionInputBoundaries(t *testing.T) { + for _, tt := range []struct { + name string + remote string + local string + want bool + }{ + {name: "v prefix", remote: "v1.2.4", local: "1.2.3", want: true}, + {name: "build metadata ignored", remote: "1.2.3+new", local: "1.2.3+old", want: false}, + {name: "valid remote replaces development build", remote: "1.2.3", local: "DEV", want: true}, + {name: "invalid remote rejected", remote: "latest", local: "1.2.3", want: false}, + {name: "equal version", remote: "1.2.3", local: "1.2.3", want: false}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := IsNewer(tt.remote, tt.local); got != tt.want { + t.Fatalf("IsNewer(%q, %q) = %v, want %v", tt.remote, tt.local, got, tt.want) + } + }) + } +} + func TestIsCIEnv(t *testing.T) { for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} { t.Run(key, func(t *testing.T) { From 5facf15d2a57ad5863e5bc690d2cb9d9c78642fa Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:23:16 +0800 Subject: [PATCH 17/18] fix: improve distribution update resilience --- cmd/doctor/doctor_test.go | 22 ++++--- cmd/root.go | 9 ++- cmd/update/update.go | 6 +- cmd/update/update_test.go | 20 ++++++ extension/transport/registry_test.go | 33 ---------- internal/distribution/binary.go | 3 + internal/distribution/download.go | 8 ++- internal/distribution/download_test.go | 12 +++- internal/distribution/install.go | 1 + internal/distribution/install_test.go | 24 +++++++- internal/distribution/manifest.go | 11 ++++ internal/distribution/prepare_test.go | 85 +++++++++++++++++++++++--- internal/distribution/skills.go | 34 +++++++---- internal/skillscheck/check.go | 8 ++- internal/skillscheck/check_test.go | 16 +++++ internal/skillscheck/state.go | 16 ++++- internal/skillscheck/sync.go | 1 + internal/transport/extension_test.go | 30 +-------- internal/update/update.go | 13 +--- internal/update/update_test.go | 17 +++++- internal/urlrewrite/rewrite_test.go | 43 ------------- 21 files changed, 255 insertions(+), 157 deletions(-) diff --git a/cmd/doctor/doctor_test.go b/cmd/doctor/doctor_test.go index d58025e1d7..3c65f4d070 100644 --- a/cmd/doctor/doctor_test.go +++ b/cmd/doctor/doctor_test.go @@ -7,7 +7,9 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" + "net/http/httptest" "strings" "testing" @@ -19,33 +21,39 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/distribution" "github.com/larksuite/cli/internal/recovery" "github.com/larksuite/cli/internal/surface" "github.com/larksuite/cli/internal/update" ) -type doctorManifestProvider struct{} +type doctorManifestProvider struct{ manifestURL string } func (doctorManifestProvider) Name() string { return "doctor-manifest-test" } func (doctorManifestProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { return nil } -func (doctorManifestProvider) ResolveManifestURL(context.Context) string { - return "https://dist.example/manifest.json" +func (p doctorManifestProvider) ResolveManifestURL(context.Context) string { + return p.manifestURL } func TestCheckCLIUpdateReportsDifferentOpaqueManifestTarget(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf(w, `{"schema":1,"version":"older-channel","artifacts":{"skills":{"url":"https://distribution.example/skills.zip","checksum":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},%q:{"url":"https://distribution.example/cli.zip","checksum":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}}`, distribution.CurrentPlatformKey()) + })) + defer server.Close() previousProvider := exttransport.GetProvider() previousFetch := fetchLatestForDoctor + previousClient := distribution.DefaultClient previousVersion := build.Version - exttransport.Register(doctorManifestProvider{}) - fetchLatestForDoctor = func() (update.Target, error) { - return update.Target{Version: "older-channel", Exact: true}, nil - } + exttransport.Register(doctorManifestProvider{manifestURL: server.URL}) + distribution.DefaultClient = server.Client() + fetchLatestForDoctor = update.FetchTarget build.Version = "newer-channel" t.Cleanup(func() { exttransport.Register(previousProvider) fetchLatestForDoctor = previousFetch + distribution.DefaultClient = previousClient build.Version = previousVersion }) checks := checkCLIUpdate() diff --git a/cmd/root.go b/cmd/root.go index a9509e6473..d0ceffe16d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,6 +21,7 @@ import ( "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/deprecation" + "github.com/larksuite/cli/internal/distribution" "github.com/larksuite/cli/internal/flagalias" "github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/output" @@ -142,7 +143,13 @@ func isDeferredBootstrapProfileError(err error) bool { var ( checkCachedUpdate = update.CheckCached refreshUpdateCache = update.RefreshCache - initializeSkillsCheck = skillscheck.Init + initializeSkillsCheck = func(version string) { + sourceIdentity := skillscheck.OfficialSourceIdentity + if manifestURL, enabled, err := distribution.ResolveManifestURL(context.Background()); err == nil && enabled { + sourceIdentity = distribution.ManifestSourceIdentity(manifestURL) + } + skillscheck.InitForSource(version, sourceIdentity) + } ) // setupNotices wires both the binary update notice and the skills diff --git a/cmd/update/update.go b/cmd/update/update.go index 0e29abcd5a..7f758510ed 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -458,7 +458,8 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state layout, _ := skillscheck.ParseLayout(requestedLayout) if !force { if state, ok, err := skillscheck.ReadState(); err == nil && ok && normalizeVersion(state.Version) == normalizeVersion(stateVersion) { - if !state.OfficialSkillsUnknown && (layout == "" || skillscheck.EffectiveLayout(state) == layout) { + if !state.OfficialSkillsUnknown && skillscheck.MatchesSource(state, skillscheck.OfficialSourceIdentity) && + (layout == "" || skillscheck.EffectiveLayout(state) == layout) { return nil } } @@ -524,7 +525,8 @@ func applySkillsStatus(env map[string]interface{}, target string) { status := map[string]interface{}{ "current": state.Version, "target": target, - "in_sync": normalizeVersion(state.Version) == normalizeVersion(target) && !state.OfficialSkillsUnknown, + "in_sync": normalizeVersion(state.Version) == normalizeVersion(target) && + !state.OfficialSkillsUnknown && skillscheck.MatchesSource(state, skillscheck.OfficialSourceIdentity), } if state.OfficialSkillsUnknown { status["official_unknown"] = true diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index c5156ce143..42809a5138 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -1373,6 +1373,26 @@ func TestRunSkillsAndState_UnknownOfficialSkillsBypassesVersionDedup(t *testing. } } +func TestRunSkillsAndState_ManifestSourceBypassesVersionDedup(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := skillscheck.WriteState(skillscheck.SkillsState{ + Version: "1.0.21", + SourceIdentity: "manifest:test", + }); err != nil { + t.Fatal(err) + } + originalSync := syncSkills + t.Cleanup(func() { syncSkills = originalSync }) + called := false + syncSkills = func(skillscheck.SyncOptions) *skillscheck.SyncResult { + called = true + return &skillscheck.SyncResult{Action: "synced"} + } + if got := runSkillsAndState(&selfupdate.Updater{}, newTestIO(), "1.0.21", false, ""); !called || got == nil { + t.Fatalf("runSkillsAndState() = %+v, called = %v", got, called) + } +} + func TestSkillsSummaryMarksUnknownOfficialSkills(t *testing.T) { summary := skillsSummary(&skillscheck.SyncResult{ Layout: skillscheck.LayoutSeparate, diff --git a/extension/transport/registry_test.go b/extension/transport/registry_test.go index c7b3ded1fe..2b07cde9d6 100644 --- a/extension/transport/registry_test.go +++ b/extension/transport/registry_test.go @@ -22,19 +22,6 @@ type stubProvider struct { func (s *stubProvider) Name() string { return s.name } func (s *stubProvider) ResolveInterceptor(context.Context) Interceptor { return &stubInterceptor{} } -type stubURLRewriterProvider struct { - stubProvider - rewriter URLRewriter -} - -func (s *stubURLRewriterProvider) ResolveURLRewriter(context.Context) URLRewriter { - return s.rewriter -} - -type stubURLRewriter func(string) string - -func (f stubURLRewriter) RewriteURL(rawURL string) string { return f(rawURL) } - type stubDistributionProvider struct { stubProvider manifestURL string @@ -98,26 +85,6 @@ func TestResolveInterceptor_ReturnsNonNil(t *testing.T) { } } -func TestURLRewriterProviderIsOptional(t *testing.T) { - previous := GetProvider() - Register(nil) - t.Cleanup(func() { Register(previous) }) - - p := &stubURLRewriterProvider{ - stubProvider: stubProvider{name: "rewrite"}, - rewriter: stubURLRewriter(func(string) string { return "https://mirror.example.test" }), - } - Register(p) - - rewriterProvider, ok := GetProvider().(URLRewriterProvider) - if !ok { - t.Fatalf("registered provider does not implement URLRewriterProvider") - } - if got := rewriterProvider.ResolveURLRewriter(context.Background()).RewriteURL("https://source.example.test"); got != "https://mirror.example.test" { - t.Fatalf("RewriteURL() = %q", got) - } -} - func TestDistributionProviderIsOptional(t *testing.T) { previous := GetProvider() t.Cleanup(func() { Register(previous) }) diff --git a/internal/distribution/binary.go b/internal/distribution/binary.go index 963489b800..512ca9f0cb 100644 --- a/internal/distribution/binary.go +++ b/internal/distribution/binary.go @@ -74,6 +74,9 @@ func matchesVersionOutput(output, version string) bool { } func replaceBinary(staged, target string) (func(), error) { + // The backup supports error-path rollback, but this process cannot recover + // from termination between the two renames. A later installer may safely + // promote a newly staged binary while preserving the existing backup. backupPath := target + ".old" targetExists, err := pathExists(target) if err != nil { diff --git a/internal/distribution/download.go b/internal/distribution/download.go index 208b3ab606..993fedcfc9 100644 --- a/internal/distribution/download.go +++ b/internal/distribution/download.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "strings" + "time" "github.com/larksuite/cli/internal/vfs" ) @@ -18,9 +19,14 @@ import ( // These ceilings bound temporary disk use while leaving ample room for the // CLI and Skills bundles. Raise them only when a supported bundle outgrows // the current distribution contract. -const artifactDownloadMaxBytes int64 = 4 << 30 +const ( + artifactDownloadMaxBytes int64 = 4 << 30 + artifactDownloadTimeout = 10 * time.Minute +) func downloadArtifact(ctx context.Context, artifact Artifact, directory, pattern string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, artifactDownloadTimeout) + defer cancel() return downloadArtifactWithLimit(ctx, artifact, directory, pattern, artifactDownloadMaxBytes) } diff --git a/internal/distribution/download_test.go b/internal/distribution/download_test.go index 1f4911d1e8..c16f84ed30 100644 --- a/internal/distribution/download_test.go +++ b/internal/distribution/download_test.go @@ -12,6 +12,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" ) func TestDownloadArtifactRejectsExcessiveBody(t *testing.T) { @@ -30,12 +31,17 @@ func TestDownloadArtifactRejectsExcessiveBody(t *testing.T) { } } -func TestDownloadArtifactDoesNotApplyManifestDeadline(t *testing.T) { +func TestDownloadArtifactAppliesTenMinuteDeadline(t *testing.T) { payload := []byte("artifact") previousClient := DefaultClient DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if _, ok := req.Context().Deadline(); ok { - t.Fatal("artifact request inherited the manifest deadline") + deadline, ok := req.Context().Deadline() + if !ok { + t.Fatal("artifact request has no deadline") + } + remaining := time.Until(deadline) + if remaining < 9*time.Minute || remaining > artifactDownloadTimeout { + t.Fatalf("artifact deadline remaining = %s, want about %s", remaining, artifactDownloadTimeout) } return &http.Response{ StatusCode: http.StatusOK, diff --git a/internal/distribution/install.go b/internal/distribution/install.go index 922cdc1fa1..642ec33be0 100644 --- a/internal/distribution/install.go +++ b/internal/distribution/install.go @@ -91,6 +91,7 @@ func installPrepared(prepared *preparedUpdate, opts InstallOptions) error { prepared.SkillNames, previous, ) + state.SourceIdentity = prepared.Manifest.sourceIdentity if err := skillscheck.WriteState(state); err != nil { return rollback(fmt.Errorf("write Skills state: %w", err)) } diff --git a/internal/distribution/install_test.go b/internal/distribution/install_test.go index 4efee6027c..a3b3679c20 100644 --- a/internal/distribution/install_test.go +++ b/internal/distribution/install_test.go @@ -122,6 +122,28 @@ func TestInstallSkillsToTargetsRollsBackEarlierTarget(t *testing.T) { assertFile(t, filepath.Join(first, "managed", "SKILL.md"), "old") } +func TestFailedSkillsRollbackRetainsBackup(t *testing.T) { + root := t.TempDir() + stage := filepath.Join(root, "stage") + backup := filepath.Join(root, "backup") + if err := vfs.MkdirAll(stage, 0o755); err != nil { + t.Fatal(err) + } + if err := vfs.MkdirAll(backup, 0o755); err != nil { + t.Fatal(err) + } + rollbackErr := errors.New("restore failed") + if err := finishSkillsRollback(stage, backup, rollbackErr); !errors.Is(err, rollbackErr) { + t.Fatalf("rollback error = %v, want %v", err, rollbackErr) + } + if _, err := vfs.Stat(stage); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("staging directory remains after rollback: %v", err) + } + if _, err := vfs.Stat(backup); err != nil { + t.Fatalf("backup removed after failed rollback: %v", err) + } +} + func TestInstallPreparedVerificationFailureDoesNotMutate(t *testing.T) { root := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")) @@ -175,7 +197,7 @@ func TestInstallPreparedBinaryCommitFailureRollsBackSkillsAndState(t *testing.T) assertFile(t, executable, "old") } -func TestReplaceBinaryRecoversWhenOnlyBackupExists(t *testing.T) { +func TestReplaceBinaryPromotesStagedWhenOnlyBackupExists(t *testing.T) { root := t.TempDir() target := filepath.Join(root, "lark-cli") backup := target + ".old" diff --git a/internal/distribution/manifest.go b/internal/distribution/manifest.go index b9c1d603d1..683500c9db 100644 --- a/internal/distribution/manifest.go +++ b/internal/distribution/manifest.go @@ -8,6 +8,8 @@ package distribution import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -44,6 +46,14 @@ type Manifest struct { Schema int `json:"schema"` Version string `json:"version"` Artifacts map[string]Artifact `json:"artifacts"` + + sourceIdentity string +} + +// ManifestSourceIdentity identifies one manifest without persisting its URL. +func ManifestSourceIdentity(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return "manifest:" + hex.EncodeToString(sum[:]) } // DefaultClient overrides the manifest/artifact client in tests. Production @@ -110,6 +120,7 @@ func fetchManifest(ctx context.Context, manifestURL string) (*Manifest, error) { if err != nil { return nil, err } + manifest.sourceIdentity = ManifestSourceIdentity(manifestURL) return manifest, nil } diff --git a/internal/distribution/prepare_test.go b/internal/distribution/prepare_test.go index e6605caaf6..ce6c109ab7 100644 --- a/internal/distribution/prepare_test.go +++ b/internal/distribution/prepare_test.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "crypto/sha256" + "errors" "fmt" "io" "net/http" @@ -15,8 +16,10 @@ import ( "path/filepath" "reflect" "runtime" + "strings" "testing" + "github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/vfs" ) @@ -30,11 +33,13 @@ func TestInstallDownloadsAndCommitsManifestArtifacts(t *testing.T) { if runtime.GOOS == "windows" { executableName += ".exe" } - binaryArchive := buildTestZip(t, map[string]string{executableName: "new binary"}) - skillsArchive := buildTestZip(t, map[string]string{ - "README.md": "bundle metadata", - "lark-alpha/SKILL.md": "alpha", - "lark-beta/SKILL.md": "beta", + binaryArchive := buildTestZip(t, map[string]testZipFile{executableName: {content: "new binary", mode: 0o755}}) + skillsArchive := buildTestZip(t, map[string]testZipFile{ + "README.md": {content: "bundle metadata"}, + "lark-alpha/SKILL.md": {content: "alpha"}, + "lark-alpha/references/guide.md": {content: "guide"}, + "lark-alpha/scripts/check-install": {content: "#!/bin/sh\n", mode: 0o755}, + "lark-beta/SKILL.md": {content: "beta"}, }) payloads := map[string][]byte{ "/cli.zip": binaryArchive, @@ -56,7 +61,8 @@ func TestInstallDownloadsAndCommitsManifestArtifacts(t *testing.T) { t.Cleanup(func() { DefaultClient = previousClient }) manifest := &Manifest{ - Version: "release-channel-7", + Version: "release-channel-7", + sourceIdentity: "test-manifest", Artifacts: map[string]Artifact{ CurrentPlatformKey(): {URL: "https://dist.example/cli.zip", Checksum: checksumFor(binaryArchive)}, SkillsKey: {URL: "https://dist.example/skills.zip", Checksum: checksumFor(skillsArchive)}, @@ -85,7 +91,57 @@ func TestInstallDownloadsAndCommitsManifestArtifacts(t *testing.T) { } assertFile(t, executable, "new binary") assertFile(t, filepath.Join(skillsDir, "lark-alpha", "SKILL.md"), "alpha") + assertFile(t, filepath.Join(skillsDir, "lark-alpha", "references", "guide.md"), "guide") assertFile(t, filepath.Join(skillsDir, "lark-beta", "SKILL.md"), "beta") + script := filepath.Join(skillsDir, "lark-alpha", "scripts", "check-install") + info, statErr := os.Stat(script) + if statErr != nil { + t.Fatal(statErr) + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { + t.Fatalf("script mode = %v, want executable", info.Mode().Perm()) + } + state, ok, readErr := skillscheck.ReadState() + if readErr != nil || !ok || state.SourceIdentity != "test-manifest" { + t.Fatalf("Skills state = %#v, %v, %v", state, ok, readErr) + } +} + +func TestInstallRejectsChecksumMismatchBeforeBinaryVerification(t *testing.T) { + root := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")) + executableName := "lark-cli" + if runtime.GOOS == "windows" { + executableName += ".exe" + } + archive := buildTestZip(t, map[string]testZipFile{executableName: {content: "new binary"}}) + previousClient := DefaultClient + DefaultClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(archive)), + Header: make(http.Header), + }, nil + })} + t.Cleanup(func() { DefaultClient = previousClient }) + manifest := &Manifest{Version: "target", Artifacts: map[string]Artifact{ + CurrentPlatformKey(): {URL: "https://distribution.example/cli.zip", Checksum: "sha256:" + strings.Repeat("0", 64)}, + SkillsKey: {URL: "https://distribution.example/skills.zip", Checksum: checksumFor(archive)}, + }} + verified := false + err := Install(context.Background(), manifest, InstallOptions{ + ExecutablePath: filepath.Join(root, executableName), + VerifyBinary: func(string, string) error { + verified = true + return nil + }, + }) + if err == nil || errors.Unwrap(err) == nil || !strings.Contains(errors.Unwrap(err).Error(), "checksum mismatch") { + t.Fatalf("Install() error = %v, want checksum mismatch", err) + } + if verified { + t.Fatal("binary verification ran before checksum validation") + } } func TestListSkillsIgnoresRootFiles(t *testing.T) { @@ -105,16 +161,25 @@ func TestListSkillsIgnoresRootFiles(t *testing.T) { } } -func buildTestZip(t *testing.T, files map[string]string) []byte { +type testZipFile struct { + content string + mode os.FileMode +} + +func buildTestZip(t *testing.T, files map[string]testZipFile) []byte { t.Helper() var data bytes.Buffer writer := zip.NewWriter(&data) - for name, content := range files { - entry, err := writer.Create(name) + for name, file := range files { + header := &zip.FileHeader{Name: name, Method: zip.Deflate} + if file.mode != 0 { + header.SetMode(file.mode) + } + entry, err := writer.CreateHeader(header) if err != nil { t.Fatal(err) } - if _, err := entry.Write([]byte(content)); err != nil { + if _, err := entry.Write([]byte(file.content)); err != nil { t.Fatal(err) } } diff --git a/internal/distribution/skills.go b/internal/distribution/skills.go index 82af1cd8f9..96c6c7aa25 100644 --- a/internal/distribution/skills.go +++ b/internal/distribution/skills.go @@ -55,27 +55,21 @@ func installSkills(prepared *preparedUpdate, target string, previous *skillschec first = err } } - return first + return finishSkillsRollback(stage, backup, first) } for _, name := range managed { current := filepath.Join(target, name) if _, err := vfs.Stat(current); err == nil { if err := vfs.Rename(current, filepath.Join(backup, name)); err != nil { - _ = rollback() - cleanup() - return nil, nil, err + return nil, nil, failAfterRollback(err, rollback) } movedOld = append(movedOld, name) } else if !os.IsNotExist(err) { - _ = rollback() - cleanup() - return nil, nil, err + return nil, nil, failAfterRollback(err, rollback) } if contains(prepared.SkillNames, name) { if err := vfs.Rename(filepath.Join(stage, name), current); err != nil { - _ = rollback() - cleanup() - return nil, nil, err + return nil, nil, failAfterRollback(err, rollback) } movedNew = append(movedNew, name) } @@ -103,9 +97,8 @@ func installSkillsToTargets(prepared *preparedUpdate, targets []string, previous for _, target := range targets { rollback, finalize, err := installSkills(prepared, target, previous) if err != nil { - _ = rollbackAll() - finalizeAll() - return nil, nil, fmt.Errorf("install Skills to %s: %w", target, err) + cause := fmt.Errorf("install Skills to %s: %w", target, err) + return nil, nil, failAfterRollback(cause, rollbackAll) } rollbacks = append(rollbacks, rollback) finalizers = append(finalizers, finalize) @@ -113,6 +106,21 @@ func installSkillsToTargets(prepared *preparedUpdate, targets []string, previous return rollbackAll, finalizeAll, nil } +func failAfterRollback(cause error, rollback func() error) error { + if err := rollback(); err != nil { + return fmt.Errorf("%w (rollback failed: %v; backup retained)", cause, err) + } + return cause +} + +func finishSkillsRollback(stage, backup string, rollbackErr error) error { + _ = vfs.RemoveAll(stage) + if rollbackErr == nil { + _ = vfs.RemoveAll(backup) + } + return rollbackErr +} + func copyTree(source, destination string) error { entries, err := vfs.ReadDir(source) if err != nil { diff --git a/internal/skillscheck/check.go b/internal/skillscheck/check.go index d1425ea816..910259bd6e 100644 --- a/internal/skillscheck/check.go +++ b/internal/skillscheck/check.go @@ -14,6 +14,11 @@ import "strings" // Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver, // LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out). func Init(currentVersion string) { + InitForSource(currentVersion, OfficialSourceIdentity) +} + +// InitForSource also considers which distribution owns the installed Skills. +func InitForSource(currentVersion, sourceIdentity string) { SetPending(nil) if shouldSkip(currentVersion) { return @@ -22,7 +27,8 @@ func Init(currentVersion string) { if err != nil || !ok || state.Version == "" { return } - if strings.TrimPrefix(strings.TrimPrefix(state.Version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") && !state.OfficialSkillsUnknown { + if strings.TrimPrefix(strings.TrimPrefix(state.Version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") && + !state.OfficialSkillsUnknown && MatchesSource(state, sourceIdentity) { return } SetPending(&StaleNotice{ diff --git a/internal/skillscheck/check_test.go b/internal/skillscheck/check_test.go index f3b11890eb..7921efa729 100644 --- a/internal/skillscheck/check_test.go +++ b/internal/skillscheck/check_test.go @@ -51,6 +51,22 @@ func TestInit_NormalizedVersion_NoNotice(t *testing.T) { } } +func TestInitForSourceNoticesAtSameVersionWhenSourceChanges(t *testing.T) { + clearSkillsSkipEnv(t) + resetPending(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := WriteState(SkillsState{ + Version: "1.0.21", + SourceIdentity: "manifest:first", + }); err != nil { + t.Fatal(err) + } + InitForSource("1.0.21", "manifest:second") + if got := GetPending(); got == nil { + t.Fatal("GetPending() = nil, want notice for a changed Skills source") + } +} + func TestInit_OfficialSkillsUnknown_NoticeAtSameVersion(t *testing.T) { clearSkillsSkipEnv(t) resetPending(t) diff --git a/internal/skillscheck/state.go b/internal/skillscheck/state.go index 6e425f06e3..48d27bc829 100644 --- a/internal/skillscheck/state.go +++ b/internal/skillscheck/state.go @@ -18,13 +18,15 @@ import ( ) const ( - stateFile = "skills-state.json" + stateFile = "skills-state.json" + OfficialSourceIdentity = "official" ) var ErrUnreadableState = errors.New("skills state is unreadable") type SkillsState struct { Version string `json:"version"` + SourceIdentity string `json:"source_identity,omitempty"` Layout Layout `json:"layout,omitempty"` OfficialSkills []string `json:"official_skills"` OfficialSkillsUnknown bool `json:"official_skills_unknown,omitempty"` @@ -34,6 +36,18 @@ type SkillsState struct { UpdatedAt string `json:"updated_at"` } +// MatchesSource reports whether state belongs to the expected Skills source. +// States written before source tracking are treated as the official source. +func MatchesSource(state *SkillsState, expected string) bool { + if state == nil { + return false + } + if state.SourceIdentity == "" { + return expected == OfficialSourceIdentity + } + return state.SourceIdentity == expected +} + // KnownOfficialSkills returns the previous managed Skill set when the state is // authoritative. Callers receive a copy so installation planning cannot mutate // the persisted state in memory. diff --git a/internal/skillscheck/sync.go b/internal/skillscheck/sync.go index 4239a9e0df..f05d6d2dbc 100644 --- a/internal/skillscheck/sync.go +++ b/internal/skillscheck/sync.go @@ -500,6 +500,7 @@ func finishSync(opts SyncOptions, layout Layout, plan SyncPlan, action, warning } state := SkillsState{ Version: opts.Version, + SourceIdentity: OfficialSourceIdentity, Layout: layout, OfficialSkills: plan.OfficialSkills, OfficialSkillsUnknown: officialUnknown, diff --git a/internal/transport/extension_test.go b/internal/transport/extension_test.go index 35a925aa88..fdee0bd3cf 100644 --- a/internal/transport/extension_test.go +++ b/internal/transport/extension_test.go @@ -37,14 +37,10 @@ func (p testProvider) ResolveInterceptor(context.Context) exttransport.Intercept type rewriteTestProvider struct { testProvider - rewriter exttransport.URLRewriter - rewriteCalls *int + rewriter exttransport.URLRewriter } func (p rewriteTestProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { - if p.rewriteCalls != nil { - *p.rewriteCalls++ - } return p.rewriter } @@ -378,30 +374,6 @@ func TestHTTPPolicyRouterRejectsUnparsableRewriteBeforeBase(t *testing.T) { } } -func TestHTTPPolicyRouterResolvesURLRewriterOnce(t *testing.T) { - interceptorCalls := 0 - rewriteCalls := 0 - previousProvider := exttransport.GetProvider() - exttransport.Register(rewriteTestProvider{ - testProvider: testProvider{resolveCalls: &interceptorCalls}, - rewriter: rewriteFunc(func(rawURL string) string { return rawURL }), - rewriteCalls: &rewriteCalls, - }) - t.Cleanup(func() { exttransport.Register(previousProvider) }) - - base := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return noContentResponse(req), nil - }) - _ = NewHTTPPolicyRouter(base, base) - - if interceptorCalls != 1 { - t.Fatalf("ResolveInterceptor() calls = %d, want 1 per router", interceptorCalls) - } - if rewriteCalls != 1 { - t.Fatalf("ResolveURLRewriter() calls = %d, want 1 per router", rewriteCalls) - } -} - func TestSDKBootstrapBridgeBlocksCrossOriginRedirectAfterSameOriginHop(t *testing.T) { var externalCalls atomic.Int32 var relayBody string diff --git a/internal/update/update.go b/internal/update/update.go index 06a2c0f900..6f4dbf0c7b 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -5,8 +5,6 @@ package update import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "io" @@ -92,7 +90,7 @@ func CheckCached(currentVersion string) *UpdateInfo { return nil } if manifestMode { - if state.Source != manifestSourceKey(manifestURL) || state.LatestVersion == currentVersion { + if state.Source != distribution.ManifestSourceIdentity(manifestURL) || state.LatestVersion == currentVersion { return nil } return &UpdateInfo{Current: currentVersion, Latest: state.LatestVersion, Source: "manifest"} @@ -116,7 +114,7 @@ func RefreshCache(currentVersion string) { state, _ := loadState() identityMatches := !manifestMode && state != nil && state.Source == "" if manifestMode { - identityMatches = state != nil && state.Source == manifestSourceKey(manifestURL) + identityMatches = state != nil && state.Source == distribution.ManifestSourceIdentity(manifestURL) } if identityMatches && time.Since(time.Unix(state.CheckedAt, 0)) < cacheTTL { return // cache is fresh @@ -127,7 +125,7 @@ func RefreshCache(currentVersion string) { } sourceKey := "" if manifestMode { - sourceKey = manifestSourceKey(manifestURL) + sourceKey = distribution.ManifestSourceIdentity(manifestURL) } _ = saveState(&updateState{ LatestVersion: target.Version, @@ -136,11 +134,6 @@ func RefreshCache(currentVersion string) { }) } -func manifestSourceKey(raw string) string { - sum := sha256.Sum256([]byte(raw)) - return "manifest:" + hex.EncodeToString(sum[:]) -} - func shouldSkipForMode(version string, manifestMode bool) bool { if manifestMode { if os.Getenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER") != "" || IsCIEnv() { diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 736d894801..79cf17f819 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -62,13 +62,17 @@ func TestManifestCacheUsesExactTargetAndSourceIdentity(t *testing.T) { if r.Header.Get("X-External-Route") != "" { t.Fatal("manifest request passed through the request interceptor") } - fmt.Fprintf(w, `{"schema":1,"version":"old-target","artifacts":{"skills":{"url":"https://dist.example/skills","checksum":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},%q:{"url":"https://dist.example/binary","checksum":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}}`, distribution.CurrentPlatformKey()) + target := "old-target" + if r.URL.Path == "/second" { + target = "second-target" + } + fmt.Fprintf(w, `{"schema":1,"version":%q,"artifacts":{"skills":{"url":"https://dist.example/skills","checksum":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},%q:{"url":"https://dist.example/binary","checksum":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}}`, target, distribution.CurrentPlatformKey()) })) defer server.Close() previousProvider := exttransport.GetProvider() previousClient := distribution.DefaultClient distribution.DefaultClient = server.Client() - exttransport.Register(updateExternalProvider{interceptor: &updateExternalInterceptor{}, manifestURL: server.URL}) + exttransport.Register(updateExternalProvider{interceptor: &updateExternalInterceptor{}, manifestURL: server.URL + "/first"}) t.Cleanup(func() { exttransport.Register(previousProvider) distribution.DefaultClient = previousClient @@ -86,6 +90,15 @@ func TestManifestCacheUsesExactTargetAndSourceIdentity(t *testing.T) { if info == nil || info.Latest != "old-target" || info.Source != "manifest" { t.Fatalf("CheckCached = %#v", info) } + + // A different manifest is a different source even while the 24-hour cache + // from the first source is fresh. + exttransport.Register(updateExternalProvider{manifestURL: server.URL + "/second"}) + RefreshCache("new-current") + info = CheckCached("new-current") + if info == nil || info.Latest != "second-target" { + t.Fatalf("CheckCached after source switch = %#v", info) + } } // clearSkipEnv unsets all env vars that shouldSkip checks, diff --git a/internal/urlrewrite/rewrite_test.go b/internal/urlrewrite/rewrite_test.go index 9d7e1c5043..5b989992b0 100644 --- a/internal/urlrewrite/rewrite_test.go +++ b/internal/urlrewrite/rewrite_test.go @@ -5,8 +5,6 @@ package urlrewrite import ( "context" - "strings" - "sync" "testing" exttransport "github.com/larksuite/cli/extension/transport" @@ -75,26 +73,6 @@ func TestResolveProviderUsesCapturedProvider(t *testing.T) { } } -func TestRewriteIdentityPreservesRawURL(t *testing.T) { - raw := "not a valid URL %2F?x=1+2&x=3" - withProvider(t, testProvider{rewriter: rewriteFunc(func(string) string { return raw })}) - - got := Rewrite(raw) - if got != raw { - t.Fatalf("Rewrite() = %q, want exact %q", got, raw) - } -} - -func TestRewriteAcceptsChangedAbsoluteHTTPURL(t *testing.T) { - const want = "http://mirror.example.test:8080/a%2Fb?x=1+2&x=3#fragment" - withProvider(t, testProvider{rewriter: rewriteFunc(func(string) string { return want })}) - - got := Rewrite("https://source.example.test/path") - if got != want { - t.Fatalf("Rewrite() = %q, want %q", got, want) - } -} - func TestRewriteReturnsExtensionValueVerbatim(t *testing.T) { const rewritten = "/extension-owned/value" withProvider(t, testProvider{rewriter: rewriteFunc(func(string) string { return rewritten })}) @@ -104,26 +82,5 @@ func TestRewriteReturnsExtensionValueVerbatim(t *testing.T) { } } -func TestResolverRewriteConcurrent(t *testing.T) { - withProvider(t, testProvider{rewriter: rewriteFunc(func(rawURL string) string { - return strings.Replace(rawURL, "source.example.test", "mirror.example.test", 1) - })}) - - resolver := Resolve(context.Background()) - const workers = 32 - var group sync.WaitGroup - group.Add(workers) - for range workers { - go func() { - defer group.Done() - got := resolver.Rewrite("https://source.example.test/path") - if got != "https://mirror.example.test/path" { - t.Errorf("Rewrite() = %q", got) - } - }() - } - group.Wait() -} - var _ exttransport.Provider = testProvider{} var _ exttransport.URLRewriterProvider = testProvider{} From b9cc86def4e990931faea0f2e66cdd8ed0973b52 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:33:55 +0800 Subject: [PATCH 18/18] fix: preserve rollback error causes --- internal/distribution/install_test.go | 9 +++++++++ internal/distribution/skills.go | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/distribution/install_test.go b/internal/distribution/install_test.go index a3b3679c20..2a07152a5f 100644 --- a/internal/distribution/install_test.go +++ b/internal/distribution/install_test.go @@ -144,6 +144,15 @@ func TestFailedSkillsRollbackRetainsBackup(t *testing.T) { } } +func TestFailAfterRollbackPreservesBothCauses(t *testing.T) { + cause := errors.New("install failed") + rollbackErr := errors.New("restore failed") + err := failAfterRollback(cause, func() error { return rollbackErr }) + if !errors.Is(err, cause) || !errors.Is(err, rollbackErr) { + t.Fatalf("error = %v, want both install and rollback causes", err) + } +} + func TestInstallPreparedVerificationFailureDoesNotMutate(t *testing.T) { root := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")) diff --git a/internal/distribution/skills.go b/internal/distribution/skills.go index 96c6c7aa25..446454d663 100644 --- a/internal/distribution/skills.go +++ b/internal/distribution/skills.go @@ -108,7 +108,7 @@ func installSkillsToTargets(prepared *preparedUpdate, targets []string, previous func failAfterRollback(cause error, rollback func() error) error { if err := rollback(); err != nil { - return fmt.Errorf("%w (rollback failed: %v; backup retained)", cause, err) + return fmt.Errorf("%w (rollback failed: %w; backup retained)", cause, err) } return cause }