diff --git a/banner.json b/banner.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/banner.json @@ -0,0 +1 @@ +[] diff --git a/cmd/lk/banner.go b/cmd/lk/banner.go new file mode 100644 index 00000000..04b9ee80 --- /dev/null +++ b/cmd/lk/banner.go @@ -0,0 +1,153 @@ +// Copyright 2021-2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/Masterminds/semver/v3" + + livekitcli "github.com/livekit/livekit-cli/v2" + "github.com/livekit/livekit-cli/v2/pkg/util" +) + +// banner.json on main is the list of notices shown to installed CLIs. Editing that +// file is the whole release process: no build or tag involved. +const bannerURL = "https://raw.githubusercontent.com/livekit/livekit-cli/main/banner.json" + +// The banner prints before the command, so the fetch is awaited. To keep that off +// most runs, the fetched file is cached and reused for bannerTTL. +const ( + bannerTimeout = time.Second + bannerTTL = time.Hour +) + +func bannerCachePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".livekit", "banner.json"), nil +} + +type banner struct { + Message string `json:"message"` + // Versions is a semver constraint (e.g. "< 3.0.0") selecting which CLI versions + // see the message. Empty matches every version. + Versions string `json:"versions"` +} + +// loadBanner returns banner.json from the cache when it is fresh, otherwise from +// the network, falling back to a stale cache when the fetch fails. +func loadBanner(ctx context.Context) []byte { + path, err := bannerCachePath() + if err != nil { + return nil + } + if st, err := os.Stat(path); err == nil && time.Since(st.ModTime()) < bannerTTL { + raw, _ := os.ReadFile(path) + return raw + } + raw := fetchBanner(ctx) + if raw == nil { + raw, _ = os.ReadFile(path) + return raw + } + if os.MkdirAll(filepath.Dir(path), 0700) == nil { + _ = os.WriteFile(path, raw, 0600) + } + return raw +} + +// fetchBanner returns the raw banner.json, or nil when the fetch fails or exceeds +// bannerTimeout. +func fetchBanner(ctx context.Context) []byte { + ctx, cancel := context.WithTimeout(ctx, bannerTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, bannerURL, nil) + if err != nil { + return nil + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if err != nil { + return nil + } + return body +} + +// bannerMessages returns the message of every entry whose constraint matches +// version, in file order. +func bannerMessages(raw []byte, version string) []string { + var banners []banner + if json.Unmarshal(raw, &banners) != nil { + return nil + } + v, err := semver.NewVersion(version) + if err != nil { + return nil + } + var msgs []string + for _, b := range banners { + if b.Message == "" { + continue + } + if b.Versions != "" { + c, err := semver.NewConstraint(b.Versions) + if err != nil || !c.Check(v) { + continue + } + } + msgs = append(msgs, b.Message) + } + return msgs +} + +// printBanner shows the notices for this build on an interactive terminal via +// Status, so they land on stderr and honor --quiet. Non-interactive runs (scripts, +// pipes) skip the fetch entirely. +func printBanner(ctx context.Context) { + if !out.Interactive() { + return + } + msgs := bannerMessages(loadBanner(ctx), livekitcli.Version) + if len(msgs) == 0 { + return + } + // The fence sets the notices apart from the command's own output. The fixed + // width wraps long messages instead of letting the border break on narrow + // terminals. + fence := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(util.Warning()). + Padding(0, 1). + Width(76) + out.Statusf("%s\n", fence.Render(strings.Join(msgs, "\n\n"))) +} diff --git a/cmd/lk/banner_test.go b/cmd/lk/banner_test.go new file mode 100644 index 00000000..fa4718cd --- /dev/null +++ b/cmd/lk/banner_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestBannerMessages(t *testing.T) { + cases := []struct { + name, raw, version string + want []string + }{ + {"no constraint shows to everyone", `[{"message":"hi"}]`, "2.18.6", []string{"hi"}}, + {"matching constraint", `[{"message":"upgrade","versions":"< 3.0.0"}]`, "2.18.6", []string{"upgrade"}}, + {"non-matching constraint", `[{"message":"upgrade","versions":"< 3.0.0"}]`, "3.0.0", nil}, + {"every matching entry, in order", `[ + {"message":"simulate is out","versions":"< 2.18.0"}, + {"message":"3.0 is out","versions":"< 3.0.0"}, + {"message":"hello 3.x","versions":">= 3.0.0"} + ]`, "2.17.0", []string{"simulate is out", "3.0 is out"}}, + {"empty message skipped", `[{"message":"","versions":""},{"message":"hi"}]`, "2.18.6", []string{"hi"}}, + {"empty list", `[]`, "2.18.6", nil}, + {"invalid json", `[`, "2.18.6", nil}, + {"invalid constraint skipped", `[{"message":"hi","versions":"???"},{"message":"ok"}]`, "2.18.6", []string{"ok"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := bannerMessages([]byte(c.raw), c.version); !reflect.DeepEqual(got, c.want) { + t.Fatalf("got %q, want %q", got, c.want) + } + }) + } +} diff --git a/cmd/lk/main.go b/cmd/lk/main.go index 8c6c43d3..b6355f39 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -95,7 +95,8 @@ func main() { checkForLegacyName() - if err := app.Run(ctx, os.Args); err != nil { + err := app.Run(ctx, os.Args) + if err != nil { errStyle := lipgloss.NewStyle().Foreground(util.Error()) // Outside the Printer's reach (it may not be initialized yet), so the // color profile has to be applied here too — Lip Gloss v2 emits styles @@ -153,6 +154,8 @@ func initLogger(ctx context.Context, cmd *cli.Command) (context.Context, error) } util.DetectBackground() + printBanner(ctx) + return nil, nil }