diff --git a/cmd/build.go b/cmd/build.go index 9b30053f05..85d3715517 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -374,6 +374,7 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, // mechanically unchanged. var hasConcealedCommands bool runtime.surface, hasConcealedCommands = applyDistributionPresentation(rootCmd, cfg.presentation, denied) + rootCmd.SetUsageTemplate(rewrittenRootUsageTemplate(runtime.surface)) // Resolve skill assets and canonical references before installing hooks. // A declared customization is a build-integrity boundary: failure must diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index 6e4a621933..1aba7ce52e 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -241,24 +241,24 @@ 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 { - latest, err := fetchLatestForDoctor() + target, err := fetchLatestForDoctor() if err != nil { return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")} } current := build.Version - if update.IsNewer(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 6da687089f..3c65f4d070 100644 --- a/cmd/doctor/doctor_test.go +++ b/cmd/doctor/doctor_test.go @@ -7,20 +7,61 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" + "net/http/httptest" "strings" "testing" "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" + "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{ manifestURL string } + +func (doctorManifestProvider) Name() string { return "doctor-manifest-test" } +func (doctorManifestProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { + return nil +} +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{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() + 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, @@ -109,9 +150,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/event/console_url.go b/cmd/event/console_url.go index efe95597c7..45b331a409 100644 --- a/cmd/event/console_url.go +++ b/cmd/event/console_url.go @@ -12,6 +12,7 @@ import ( "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 @@ -73,13 +74,13 @@ func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (str 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(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(brand core.LarkBrand, appID string) string { host := core.ResolveEndpoints(brand).Open - return 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. 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/root_help.go b/cmd/root_help.go index a0446f6f9e..f426d1c7ae 100644 --- a/cmd/root_help.go +++ b/cmd/root_help.go @@ -4,9 +4,11 @@ package cmd import ( + "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 +144,24 @@ 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(plan *surface.Plan) string { + return renderRootUsageTemplateWithSkillsURL(plan, + urlrewrite.Rewrite("https://github.com/larksuite/cli#agent-skills")) +} diff --git a/cmd/root_test.go b/cmd/root_test.go index d952fed082..9d3c72b771 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -5,6 +5,7 @@ package cmd import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -28,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 @@ -90,6 +92,17 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) { } } +func TestBuildRewritesRootSkillsHelpURLAfterProviderRegistration(t *testing.T) { + 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") { + 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/update/manifest.go b/cmd/update/manifest.go new file mode 100644 index 0000000000..f11586ffa7 --- /dev/null +++ b/cmd/update/manifest.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdupdate + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/distribution" + "github.com/larksuite/cli/internal/output" +) + +func runManifestUpdate(ctx context.Context, opts *UpdateOptions, manifestURL string) error { + streams := opts.Factory.IOStreams + current := currentVersion() + manifest, err := distribution.FetchManifest(ctx, manifestURL) + if err != nil { + return reportDistributionError(opts, err) + } + target := manifest.Version + if opts.Check { + return reportManifestStatus(opts, current, target, true) + } + if !opts.Force && target == 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) + } + if err := distribution.Install(ctx, manifest, distribution.InstallOptions{}); err != nil { + return reportDistributionError(opts, 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 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) + if current != target { + action = "update_available" + message = fmt.Sprintf("lark-cli %s %s configured target %s", current, symArrow(), target) + } + if opts.JSON { + result := map[string]interface{}{ + "ok": true, "source": "manifest", + "previous_version": current, "current_version": current, "target_version": target, + "action": action, "message": message, + } + if check { + result["auto_update"] = true + } + output.PrintJson(streams.Out, result) + 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 reportDistributionError(opts *UpdateOptions, typed errs.TypedError) error { + errType := "update_error" + if problem, ok := errs.ProblemOf(typed); ok && problem.Category == errs.CategoryNetwork { + errType = "network" + } + return reportError(opts, opts.Factory.IOStreams, errType, typed) +} diff --git a/cmd/update/update.go b/cmd/update/update.go index 8f5d7c44ed..7f758510ed 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -4,6 +4,7 @@ package cmdupdate import ( + "context" "fmt" stdio "io" "runtime" @@ -15,10 +16,12 @@ 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" "github.com/larksuite/cli/internal/update" + "github.com/larksuite/cli/internal/urlrewrite" ) const ( @@ -30,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() } @@ -101,10 +107,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 @@ -114,7 +121,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 +135,13 @@ 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 { + if ctx == nil { + ctx = context.Background() + } io := opts.Factory.IOStreams if _, err := skillscheck.ParseLayout(opts.SkillsLayout); err != nil { return reportError(opts, io, "validation", @@ -139,6 +153,19 @@ func updateRun(opts *UpdateOptions) error { WithParam("--skills-layout"). WithHint("Remove --skills-layout when using --check.")) } + manifestURL, manifestMode, configErr := distribution.ResolveManifestURL(ctx) + if configErr != nil { + return reportError(opts, io, "configuration", configErr) + } + 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, manifestURL) + } cur := currentVersion() updater := newUpdater() // Brand only steers skills sync. updateRun skips that resolution in --check, @@ -189,6 +216,18 @@ func updateRun(opts *UpdateOptions) error { return doAutoUpdate(opts, io, cur, latest, detect, updater) } +type presentationURLs struct { + release string + changelog string +} + +func resolvePresentationURLs(latest string) presentationURLs { + return presentationURLs{ + release: urlrewrite.Rewrite(releaseURL(latest)), + changelog: urlrewrite.Rewrite(changelogURL()), + } +} + // resolveSkillsBrand returns the skills-source brand: resolved config first, // then the active profile's raw config entry (the brand is not a secret; a // locked keychain must not flip the source), then the default with a notice. @@ -230,21 +269,22 @@ func reportErrorWithFields(opts *UpdateOptions, io *cmdutil.IOStreams, errType s } 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, "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 { @@ -254,6 +294,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s } 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 { @@ -261,7 +302,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 +313,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 { @@ -287,6 +328,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri } 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 { @@ -308,12 +350,13 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string if npmResult.Err != nil { restore() combined := npmResult.CombinedOutput() + hint := permissionHint(combined, pm) 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 +368,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) @@ -355,12 +398,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 +413,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 +421,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 { @@ -395,26 +438,28 @@ func permissionHint(pmOutput, pm string) string { return "" } if pm == "pnpm" { - return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli" + return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see " + urlrewrite.Rewrite("https://pnpm.io/pnpm-cli") } - 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: " + urlrewrite.Rewrite("https://docs.npmjs.com/resolving-eacces-permissions-errors") } func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string { if updater.CanRestorePreviousVersion() { 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, 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) } - 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) } func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout string) *skillscheck.SyncResult { 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 } } @@ -480,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 68df818984..42809a5138 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -6,26 +6,119 @@ package cmdupdate import ( "bytes" "context" + "crypto/sha256" "encoding/json" "errors" "fmt" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" "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/distribution" "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 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) ResolveManifestURL(context.Context) string { + return 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 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 + 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) + } +} + // newTestFactory creates a test factory with minimal config. func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { t.Helper() @@ -907,6 +1000,31 @@ func TestReleaseURL(t *testing.T) { } } +func TestUpdateCheckRewritesPresentationURLs(t *testing.T) { + testurlrewrite.Register(t, func(rawURL string) string { + return strings.Replace(rawURL, "github.com", "mirror.example.test", 1) + }) + + 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 }() @@ -922,7 +1040,7 @@ 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 := 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) } @@ -1255,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/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/registry_test.go b/extension/transport/registry_test.go index 836cbca14f..2b07cde9d6 100644 --- a/extension/transport/registry_test.go +++ b/extension/transport/registry_test.go @@ -22,6 +22,15 @@ type stubProvider struct { func (s *stubProvider) Name() string { return s.name } func (s *stubProvider) ResolveInterceptor(context.Context) Interceptor { return &stubInterceptor{} } +type stubDistributionProvider struct { + stubProvider + manifestURL string +} + +func (s *stubDistributionProvider) ResolveManifestURL(context.Context) string { + return s.manifestURL +} + func TestGetProvider_NilByDefault(t *testing.T) { mu.Lock() provider = nil @@ -75,3 +84,20 @@ func TestResolveInterceptor_ReturnsNonNil(t *testing.T) { t.Fatal("expected non-nil Interceptor") } } + +func TestDistributionProviderIsOptional(t *testing.T) { + previous := GetProvider() + t.Cleanup(func() { Register(previous) }) + p := &stubDistributionProvider{ + stubProvider: stubProvider{name: "distribution"}, + 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.ResolveManifestURL(context.Background()); got != p.manifestURL { + t.Fatalf("ManifestURL = %q", got) + } +} diff --git a/extension/transport/types.go b/extension/transport/types.go index 61c6f04420..d01c09d73a 100644 --- a/extension/transport/types.go +++ b/extension/transport/types.go @@ -15,6 +15,37 @@ 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. 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 +} + +// DistributionProvider optionally supplies a distribution manifest in +// addition to the existing request interceptor. Providers that do not +// 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. +// 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 +} + // 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 @@ -28,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/archive.go b/internal/distribution/archive.go new file mode 100644 index 0000000000..77c6649ed0 --- /dev/null +++ b/internal/distribution/archive.go @@ -0,0 +1,173 @@ +// 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" + "strings" + + "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: + 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: + 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() { + target, err := archiveEntryPath(destination, entry.Name) + if err != nil { + return err + } + if err := vfs.MkdirAll(target, 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, err := archiveEntryPath(root, name) + if err != nil { + return err + } + 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 +} + +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 new file mode 100644 index 0000000000..7891d1da14 --- /dev/null +++ b/internal/distribution/archive_test.go @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "errors" + "io/fs" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/vfs" +) + +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 := vfs.ReadFile(filepath.Join(destination, "skill", "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if string(got) != "content" { + t.Fatalf("content = %q", got) + } + }) + } +} + +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 + 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) { + 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: name, 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 := 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(name) + 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 := vfs.WriteFile(path, data.Bytes(), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/distribution/binary.go b/internal/distribution/binary.go new file mode 100644 index 0000000000..512ca9f0cb --- /dev/null +++ b/internal/distribution/binary.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "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) { + // 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 { + 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) + } + backupExists = false + } + if targetExists { + if err := vfs.Rename(target, backupPath); err != nil { + return nil, err + } + backupExists = true + } + if err := vfs.Rename(staged, target); err != nil { + if targetExists { + _ = vfs.Rename(backupPath, target) + } + return nil, err + } + 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/config.go b/internal/distribution/config.go new file mode 100644 index 0000000000..f0b1605810 --- /dev/null +++ b/internal/distribution/config.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "context" + "fmt" + "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, errs.TypedError) { + provider := exttransport.GetProvider() + configured, ok := provider.(exttransport.DistributionProvider) + if !ok { + return "", false, nil + } + raw := strings.TrimSpace(configured.ResolveManifestURL(ctx)) + if raw == "" { + return "", false, nil + } + if err := validateDistributionURL(raw); err != nil { + return "", false, errs.NewConfigError( + errs.SubtypeInvalidConfig, + "invalid distribution manifest URL: %v", err, + ).WithCause(err) + } + return 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/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/download.go b/internal/distribution/download.go new file mode 100644 index 0000000000..993fedcfc9 --- /dev/null +++ b/internal/distribution/download.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "strings" + "time" + + "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 + 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) +} + +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..c16f84ed30 --- /dev/null +++ b/internal/distribution/download_test.go @@ -0,0 +1,57 @@ +// 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" + "time" +) + +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 TestDownloadArtifactAppliesTenMinuteDeadline(t *testing.T) { + payload := []byte("artifact") + previousClient := DefaultClient + DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + 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, + 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/errors.go b/internal/distribution/errors.go new file mode 100644 index 0000000000..397512e44b --- /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..f771d7a240 --- /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/distribution/install.go b/internal/distribution/install.go new file mode 100644 index 0000000000..642ec33be0 --- /dev/null +++ b/internal/distribution/install.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/skillscheck" + "github.com/larksuite/cli/internal/vfs" +) + +// InstallOptions supplies destinations and test seams for a distribution update. +type InstallOptions struct { + ExecutablePath string + // SkillsDir overrides automatic Agent directory discovery when non-empty. + SkillsDir string + VerifyBinary func(path, version string) 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) errs.TypedError { + 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") + } + 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 + } + + state := skillscheck.NewCompleteState( + prepared.Manifest.Version, + skillscheck.LayoutSeparate, + prepared.SkillNames, + previous, + ) + state.SourceIdentity = prepared.Manifest.sourceIdentity + 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 +} diff --git a/internal/distribution/install_test.go b/internal/distribution/install_test.go new file mode 100644 index 0000000000..2a07152a5f --- /dev/null +++ b/internal/distribution/install_test.go @@ -0,0 +1,248 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "errors" + "io/fs" + "path/filepath" + "slices" + "testing" + + "github.com/larksuite/cli/internal/skillscheck" + "github.com/larksuite/cli/internal/vfs" +) + +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 := &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") + assertFile(t, filepath.Join(skillsDir, "new-managed", "SKILL.md"), "new") + assertFile(t, filepath.Join(skillsDir, "custom", "SKILL.md"), "custom") + 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() + 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 := vfs.MkdirAll(filepath.Join(root, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + if err := vfs.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 := &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 { + 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 := &preparedUpdate{ + Manifest: &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 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 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")) + 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 := &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") + } + 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")) + 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"}} + 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 := &preparedUpdate{Manifest: &Manifest{Version: "target"}, BinaryPath: binary, SkillsRoot: filepath.Join(root, "prepared", "skills"), SkillNames: []string{"managed"}} + err := installPrepared(prepared, InstallOptions{ExecutablePath: executable, 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) + } + assertFile(t, executable, "old") +} + +func TestReplaceBinaryPromotesStagedWhenOnlyBackupExists(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 := vfs.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + 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 := vfs.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/distribution/manifest.go b/internal/distribution/manifest.go new file mode 100644 index 0000000000..683500c9db --- /dev/null +++ b/internal/distribution/manifest.go @@ -0,0 +1,204 @@ +// 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" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "runtime" + "time" + + "github.com/larksuite/cli/errs" + internaltransport "github.com/larksuite/cli/internal/transport" +) + +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. 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"` + 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 +// 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{ + // 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") + } + 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. +// 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) + 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 + } + manifest.sourceIdentity = ManifestSourceIdentity(manifestURL) + 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} +} + +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)) + 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..8e8e717123 --- /dev/null +++ b/internal/distribution/manifest_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + + internaltransport "github.com/larksuite/cli/internal/transport" +) + +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 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 { + 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(), "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 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 }{ + {"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) { + _, 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..c5cda097de --- /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 owned by one Install call. +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..ce6c109ab7 --- /dev/null +++ b/internal/distribution/prepare_test.go @@ -0,0 +1,194 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package distribution + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/larksuite/cli/internal/skillscheck" + "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]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, + "/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", + 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)}, + }, + } + 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-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) { + 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) + } +} + +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, 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(file.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/distribution/skills.go b/internal/distribution/skills.go new file mode 100644 index 0000000000..446454d663 --- /dev/null +++ b/internal/distribution/skills.go @@ -0,0 +1,196 @@ +// 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 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 { + return nil, nil, failAfterRollback(err, rollback) + } + movedOld = append(movedOld, name) + } else if !os.IsNotExist(err) { + return nil, nil, failAfterRollback(err, rollback) + } + if contains(prepared.SkillNames, name) { + if err := vfs.Rename(filepath.Join(stage, name), current); err != nil { + return nil, nil, failAfterRollback(err, rollback) + } + 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 { + cause := fmt.Errorf("install Skills to %s: %w", target, err) + return nil, nil, failAfterRollback(cause, rollbackAll) + } + rollbacks = append(rollbacks, rollback) + finalizers = append(finalizers, finalize) + } + return rollbackAll, finalizeAll, nil +} + +func failAfterRollback(cause error, rollback func() error) error { + if err := rollback(); err != nil { + return fmt.Errorf("%w (rollback failed: %w; 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 { + 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/errclass/classify.go b/internal/errclass/classify.go index 4418d756e8..3f2165dc9e 100644 --- a/internal/errclass/classify.go +++ b/internal/errclass/classify.go @@ -12,6 +12,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 @@ -569,9 +570,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(base) } - return 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/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/registry/scope_hint.go b/internal/registry/scope_hint.go index c1af42d9ff..4b58b31a84 100644 --- a/internal/registry/scope_hint.go +++ b/internal/registry/scope_hint.go @@ -8,6 +8,7 @@ import ( "net/url" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" ) // ExtractRequiredScopes pulls scope names out of the API error's @@ -59,10 +60,10 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string { if appID == "" || scope == "" { return "" } - return fmt.Sprintf( + return urlrewrite.Rewrite(fmt.Sprintf( "%s/page/scope-apply?clientID=%s&scopes=%s", core.ResolveOpenBaseURL(brand), url.QueryEscape(appID), url.QueryEscape(scope), - ) + )) } diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go index 804d34f7d0..5cbf705108 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,7 @@ func (u *Updater) InstallAllSkills(source string) *NpmResult { } func (u *Updater) StageSuite(source, dir string) *NpmResult { + source = rewriteSkillsSource(source) suiteSource := strings.TrimSuffix(strings.TrimRight(source, "/"), "/regular") + "/isolated" return u.runSkillsCommandInDir(dir, "-y", "skills", "add", suiteSource, "-s", "lark-suite", "-y") } @@ -358,6 +360,7 @@ func (u *Updater) RemoveGlobalSkills(names []string) *NpmResult { } func (u *Updater) runSkillsAdd(source string) *NpmResult { + source = rewriteSkillsSource(source) return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y") } @@ -366,12 +369,19 @@ func (u *Updater) runSkillsListGlobal() *NpmResult { } func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult { + source = rewriteSkillsSource(source) 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. +func rewriteSkillsSource(source string) string { + return urlrewrite.Rewrite(source) +} + // 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..966b29bef7 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -19,6 +19,7 @@ import ( "time" "github.com/larksuite/cli/internal/core" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" "github.com/larksuite/cli/internal/vfs" ) @@ -240,6 +241,50 @@ 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")) + 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 { + 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 TestStageSuiteUsesProvidedWorkingDirectory(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses a POSIX shell script") 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/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/skillscheck/state.go b/internal/skillscheck/state.go index 44d1d76cb6..48d27bc829 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" @@ -16,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"` @@ -32,6 +36,55 @@ 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. +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) } @@ -70,6 +123,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/skillscheck/state_test.go b/internal/skillscheck/state_test.go index 9a2b2a5dec..7e9cd7b7eb 100644 --- a/internal/skillscheck/state_test.go +++ b/internal/skillscheck/state_test.go @@ -27,6 +27,31 @@ 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) + } + 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) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) 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/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/internal/transport/extension.go b/internal/transport/extension.go index 0243e6ea0c..c64f5b6863 100644 --- a/internal/transport/extension.go +++ b/internal/transport/extension.go @@ -6,8 +6,10 @@ package transport import ( "context" "net/http" + "net/url" exttransport "github.com/larksuite/cli/extension/transport" + "github.com/larksuite/cli/internal/urlrewrite" ) var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil) @@ -15,6 +17,7 @@ var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil) type resolvedExtension struct { provider exttransport.Provider interceptor exttransport.Interceptor + rewriter *urlrewrite.Resolver } func resolveExtension() *resolvedExtension { @@ -22,11 +25,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 +46,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 +73,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 +100,28 @@ 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 := m.rewriter.Rewrite(req.URL.String()) + if rewritten != req.URL.String() { + rewrittenURL, err := url.Parse(rewritten) + if err != nil { + return nil, err + } + req.URL = rewrittenURL + req.Host = rewrittenURL.Host + } + } 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,15 +139,16 @@ 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) } -// 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) } diff --git a/internal/transport/extension_test.go b/internal/transport/extension_test.go index 5b83f7f69a..fdee0bd3cf 100644 --- a/internal/transport/extension_test.go +++ b/internal/transport/extension_test.go @@ -35,6 +35,28 @@ func (p testProvider) ResolveInterceptor(context.Context) exttransport.Intercept return p.interceptor } +type rewriteTestProvider struct { + testProvider + rewriter exttransport.URLRewriter +} + +func (p rewriteTestProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { + 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 +76,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 +201,179 @@ 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, 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) { + 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 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) { + 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 TestHTTPPolicyRouterRejectsUnparsableRewriteBeforeBase(t *testing.T) { + previousProvider := exttransport.GetProvider() + exttransport.Register(rewriteTestProvider{ + testProvider: testProvider{}, + rewriter: rewriteFunc(func(string) string { return "http://[::1" }), + }) + 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) + } + if err == nil { + t.Fatal("RoundTrip() error = nil, want URL parse error") + } + if baseCalls != 0 { + t.Fatalf("base calls = %d, want 0", baseCalls) + } +} + 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 c5c2aec600..6f4dbf0c7b 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -4,21 +4,21 @@ package update import ( + "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" - "regexp" - "strconv" - "strings" "sync/atomic" "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/versioncheck" "github.com/larksuite/cli/internal/vfs" ) @@ -35,6 +35,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 +43,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 +73,77 @@ 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) { + manifestURL, manifestMode, sourceErr := distribution.ResolveManifestURL(context.Background()) + 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 != distribution.ManifestSourceIdentity(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) { + manifestURL, manifestMode, sourceErr := distribution.ResolveManifestURL(context.Background()) + 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 == distribution.ManifestSourceIdentity(manifestURL) + } + if identityMatches && time.Since(time.Unix(state.CheckedAt, 0)) < cacheTTL { return // cache is fresh } - latest, err := fetchLatestVersion() + target, err := fetchTarget(context.Background(), manifestURL, manifestMode) if err != nil { return } + sourceKey := "" + if manifestMode { + sourceKey = distribution.ManifestSourceIdentity(manifestURL) + } _ = saveState(&updateState{ - LatestVersion: latest, + LatestVersion: target.Version, CheckedAt: time.Now().Unix(), + Source: sourceKey, }) } +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 @@ -129,15 +167,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 @@ -149,12 +179,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 --- @@ -187,10 +212,43 @@ 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). -func FetchLatest() (string, error) { - return fetchLatestVersion() +// 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 Target{}, err + } + return fetchTarget(context.Background(), manifestURL, manifestMode) +} + +func fetchTarget(ctx context.Context, manifestURL string, manifestMode bool) (Target, error) { + if manifestMode { + manifest, err := distribution.FetchManifest(ctx, manifestURL) + if err != nil { + return Target{}, err + } + return Target{Version: manifest.Version, Exact: true}, nil + } + latest, err := fetchLatestVersion() + if err != nil { + return Target{}, err + } + return Target{Version: latest}, nil } // --- npm registry --- @@ -234,125 +292,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 bda89e1a22..79cf17f819 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -6,15 +6,19 @@ 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" + "github.com/larksuite/cli/internal/vfs" ) // roundTripFunc adapts a function to http.RoundTripper. @@ -24,6 +28,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 +37,10 @@ func (p updateExternalProvider) ResolveInterceptor(context.Context) exttransport return p.interceptor } +func (p updateExternalProvider) ResolveManifestURL(context.Context) string { + return p.manifestURL +} + func (updateExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool { return class == exttransport.RequestClassExternal } @@ -46,6 +55,52 @@ 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") + } + 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 + "/first"}) + t.Cleanup(func() { + exttransport.Register(previousProvider) + distribution.DefaultClient = previousClient + }) + + RefreshCache("new-current") + stateBytes, err := vfs.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) + } + + // 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, // preventing the host environment (e.g. CI=true) from polluting test results. func clearSkipEnv(t *testing.T) { diff --git a/internal/urlrewrite/rewrite.go b/internal/urlrewrite/rewrite.go new file mode 100644 index 0000000000..2bac58d95a --- /dev/null +++ b/internal/urlrewrite/rewrite.go @@ -0,0 +1,54 @@ +// 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" + + 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 { + 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{} + } + return &Resolver{rewriter: p.ResolveURLRewriter(ctx)} +} + +// 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. 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 + } + return r.rewriter.RewriteURL(rawURL) +} diff --git a/internal/urlrewrite/rewrite_test.go b/internal/urlrewrite/rewrite_test.go new file mode 100644 index 0000000000..5b989992b0 --- /dev/null +++ b/internal/urlrewrite/rewrite_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package urlrewrite + +import ( + "context" + "testing" + + exttransport "github.com/larksuite/cli/extension/transport" +) + +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 := Rewrite(raw) + if got != raw { + t.Fatalf("Rewrite() = %q, want exact %q", got, raw) + } + }) + } +} + +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 := 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) + } +} + +func TestRewriteReturnsExtensionValueVerbatim(t *testing.T) { + const rewritten = "/extension-owned/value" + withProvider(t, testProvider{rewriter: rewriteFunc(func(string) string { return rewritten })}) + + if got := Rewrite("https://source.example.test/path"); got != rewritten { + t.Fatalf("Rewrite() = %q, want %q", got, rewritten) + } +} + +var _ exttransport.Provider = testProvider{} +var _ exttransport.URLRewriterProvider = testProvider{} 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..43340170cb --- /dev/null +++ b/internal/versioncheck/versioncheck_test.go @@ -0,0 +1,87 @@ +// 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 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) { + 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) + } + }) + } +} diff --git a/shortcuts/apps/apps_init.go b/shortcuts/apps/apps_init.go index 4fd250295f..d412748ef3 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,13 @@ 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 := urlrewrite.Rewrite(npmRegistry) 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 +423,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 +431,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 +448,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..fccf07419c 100644 --- a/shortcuts/apps/apps_init_test.go +++ b/shortcuts/apps/apps_init_test.go @@ -22,6 +22,7 @@ import ( "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" ) @@ -273,6 +274,26 @@ 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) + 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) + } + 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) + } +} + 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"}}} diff --git a/shortcuts/calendar/description_rich_images.go b/shortcuts/calendar/description_rich_images.go index 719e24ea2d..df1694b783 100644 --- a/shortcuts/calendar/description_rich_images.go +++ b/shortcuts/calendar/description_rich_images.go @@ -19,6 +19,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" ) @@ -168,5 +169,5 @@ func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, if size > 0 { u += fmt.Sprintf("&im_size=%d", size) } - return u + return urlrewrite.Rewrite(u) } diff --git a/shortcuts/common/resource_url.go b/shortcuts/common/resource_url.go index 29ec31c10e..6a3c9f16bb 100644 --- a/shortcuts/common/resource_url.go +++ b/shortcuts/common/resource_url.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" ) // BuildResourceURL returns a brand-standard, user-facing URL for a freshly @@ -33,28 +34,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 urlrewrite.Rewrite(resourceURL) } // ResourceRef holds the parsed type and token from a Lark resource URL. diff --git a/shortcuts/doc/docs_fetch_im_markdown.go b/shortcuts/doc/docs_fetch_im_markdown.go index 7c94127027..8ca47cd280 100644 --- a/shortcuts/doc/docs_fetch_im_markdown.go +++ b/shortcuts/doc/docs_fetch_im_markdown.go @@ -10,6 +10,8 @@ import ( "regexp" "strings" "unicode/utf8" + + "github.com/larksuite/cli/internal/urlrewrite" ) type imMarkdownContext struct { @@ -118,6 +120,8 @@ func newIMMarkdownContext(docInput string) imMarkdownContext { raw := strings.TrimSpace(docInput) if extracted, ok := imMarkdownBaseURLFromInput(raw); ok { base = extracted + } else { + base = urlrewrite.Rewrite(base) } return imMarkdownContext{baseURL: base} } diff --git a/shortcuts/doc/docs_fetch_im_markdown_test.go b/shortcuts/doc/docs_fetch_im_markdown_test.go index 262c48ed34..928393e1bb 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://example.larkoffice.com/base" + } + return raw + }) + + if got := newIMMarkdownContext("doc_token").baseURL; got != "https://example.larkoffice.com/base" { + t.Fatalf("baseURL = %q", got) + } +} + func TestConvertToIMMarkdownTitle(t *testing.T) { t.Parallel() @@ -1076,7 +1091,8 @@ 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 := newIMMarkdownContext(tt.input) + if got := imCtx.baseURL; got != tt.want { t.Fatalf("baseURL = %q, want %q", got, tt.want) } }) diff --git a/shortcuts/drive/drive_permission_get_setting.go b/shortcuts/drive/drive_permission_get_setting.go index 960bdab2ce..cb3be3f388 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" ) @@ -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(host + resourceKind.CanonicalPath + url.PathEscape(token)) } func validateDrivePermissionGetSettingToken(token string) error { diff --git a/shortcuts/drive/drive_permission_get_setting_test.go b/shortcuts/drive/drive_permission_get_setting_test.go index 80fc71a223..8a01608c4a 100644 --- a/shortcuts/drive/drive_permission_get_setting_test.go +++ b/shortcuts/drive/drive_permission_get_setting_test.go @@ -192,7 +192,8 @@ 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 := 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/im/chat_app_link.go b/shortcuts/im/chat_app_link.go index a07522aaee..05be024428 100644 --- a/shortcuts/im/chat_app_link.go +++ b/shortcuts/im/chat_app_link.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/shortcuts/common" ) @@ -37,7 +38,7 @@ func assembleChatAppLink(rawChatID interface{}, brand core.LarkBrand) string { q := url.Values{} q.Set("openChatId", chatID) u.RawQuery = q.Encode() - return 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 307219ea14..04c25f47be 100644 --- a/shortcuts/im/chat_app_link_test.go +++ b/shortcuts/im/chat_app_link_test.go @@ -47,7 +47,8 @@ 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 := 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 1292b7d33c..5395167c25 100644 --- a/shortcuts/im/convert_lib/content_convert.go +++ b/shortcuts/im/convert_lib/content_convert.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/shortcuts/common" ) @@ -283,7 +284,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(u.String()) } if chatID != "" && okMsgPos { u := &url.URL{Scheme: "https", Host: domain, Path: "/client/chat/open"} @@ -291,7 +292,7 @@ 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(u.String()) } return "" } diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index 8e3114216f..8aa1d49f25 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -160,7 +160,8 @@ 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 := convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources) + 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..cf0ac82d49 100644 --- a/shortcuts/im/im_messages_mget.go +++ b/shortcuts/im/im_messages_mget.go @@ -83,7 +83,8 @@ 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 := convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources) + messages = append(messages, message) } convertlib.ResolveSenderNames(runtime, messages, nameCache) diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 0c24ad2e43..46d86869b1 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -133,7 +133,8 @@ 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 := convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources) + 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..0df0a8d6b4 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" @@ -271,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, ) @@ -320,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 { diff --git a/shortcuts/mail/large_attachment_test.go b/shortcuts/mail/large_attachment_test.go index a0aa34ada7..2bb026dda3 100644 --- a/shortcuts/mail/large_attachment_test.go +++ b/shortcuts/mail/large_attachment_test.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "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" @@ -120,6 +121,26 @@ func TestBuildLargeAttachmentPreviewURL(t *testing.T) { } } +func TestBuildLargeAttachmentContentRewritesURLs(t *testing.T) { + 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) + 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 := 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 TestBuildLargeAttachmentHTML(t *testing.T) { results := []largeAttachmentResult{ {FileName: "report.pdf", FileSize: 50 * 1024 * 1024, FileToken: "tok_abc"}, diff --git a/shortcuts/okr/okr_progress_create.go b/shortcuts/okr/okr_progress_create.go index 3a56d5d9df..9ff36df970 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,7 @@ func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createPro sourceURL := runtime.Str("source-url") if sourceURL == "" { - sourceURL = core.ResolveOpenBaseURL(runtime.Config.Brand) + "/app" + sourceURL = urlrewrite.Rewrite(core.ResolveOpenBaseURL(runtime.Config.Brand) + "/app") } var progressRate *ProgressRateV1 diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index 9b1bb1a424..1c1b9b87ce 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -892,7 +892,8 @@ 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 := wikiNodeURL(core.BrandFeishu, tc.node) + if got != tc.want { t.Fatalf("wikiNodeURL() = %q, want %q", got, tc.want) } })