Skip to content
1 change: 1 addition & 0 deletions banner.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
153 changes: 153 additions & 0 deletions cmd/lk/banner.go
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this enough time for remote locations / mobile hotspot / etc?

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, I was gonna check you were using this since we had it already

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")))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe put a separator between message?

}
33 changes: 33 additions & 0 deletions cmd/lk/banner_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
5 changes: 4 additions & 1 deletion cmd/lk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -153,6 +154,8 @@ func initLogger(ctx context.Context, cmd *cli.Command) (context.Context, error)
}
util.DetectBackground()

printBanner(ctx)

return nil, nil
}

Expand Down
Loading