From c728ec396250e4a45febd583e00fba3c497c8cf0 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 11 Sep 2026 16:38:33 -0400 Subject: [PATCH 1/9] Show a remote banner fetched from banner.json on main The CLI fetches banner.json from the repo's main branch on every run and, when the message is non-empty and the semver constraint in "versions" matches the running build, prints it to stderr after the command finishes. The fetch is never awaited: whatever has not arrived by then is dropped, so no command gets slower. Non-interactive runs never see it. Editing banner.json on main is the whole publishing process, so notices such as "upgrade via brew" reach installed CLIs without a release or an auto-updater. --- banner.json | 4 ++ cmd/lk/banner.go | 99 +++++++++++++++++++++++++++++++++++++++++++ cmd/lk/banner_test.go | 23 ++++++++++ cmd/lk/main.go | 5 ++- 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 banner.json create mode 100644 cmd/lk/banner.go create mode 100644 cmd/lk/banner_test.go diff --git a/banner.json b/banner.json new file mode 100644 index 00000000..77abb9cb --- /dev/null +++ b/banner.json @@ -0,0 +1,4 @@ +{ + "message": "", + "versions": "" +} diff --git a/cmd/lk/banner.go b/cmd/lk/banner.go new file mode 100644 index 00000000..3eb91371 --- /dev/null +++ b/cmd/lk/banner.go @@ -0,0 +1,99 @@ +// 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" + "time" + + "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 notice 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" + +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"` +} + +// fetchBanner resolves to the banner text for this build, or "" when there is +// none or the fetch fails. It never delays the command: main prints whatever has +// arrived by the time the command finishes and drops the rest. +func fetchBanner(ctx context.Context) <-chan string { + ch := make(chan string, 1) + go func() { + defer close(ch) + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, bannerURL, nil) + if err != nil { + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if err != nil { + return + } + ch <- bannerMessage(body, livekitcli.Version) + }() + return ch +} + +func bannerMessage(raw []byte, version string) string { + var b banner + if json.Unmarshal(raw, &b) != nil || b.Message == "" { + return "" + } + if b.Versions != "" { + c, err := semver.NewConstraint(b.Versions) + v, verr := semver.NewVersion(version) + if err != nil || verr != nil || !c.Check(v) { + return "" + } + } + return b.Message +} + +// printBanner shows a fetched banner on an interactive terminal. Non-interactive +// runs (scripts, pipes) and runs that finished before the fetch never see it. +func printBanner(ch <-chan string) { + if !out.Interactive() { + return + } + select { + case msg := <-ch: + if msg != "" { + out.Warnf("\n%s", util.Warn(msg)) + } + default: + } +} diff --git a/cmd/lk/banner_test.go b/cmd/lk/banner_test.go new file mode 100644 index 00000000..12457e24 --- /dev/null +++ b/cmd/lk/banner_test.go @@ -0,0 +1,23 @@ +package main + +import "testing" + +func TestBannerMessage(t *testing.T) { + cases := []struct { + name, raw, version, want string + }{ + {"no constraint shows to everyone", `{"message":"hi"}`, "2.18.6", "hi"}, + {"matching constraint", `{"message":"upgrade","versions":"< 3.0.0"}`, "2.18.6", "upgrade"}, + {"non-matching constraint", `{"message":"upgrade","versions":"< 3.0.0"}`, "3.0.0", ""}, + {"empty message", `{"message":"","versions":""}`, "2.18.6", ""}, + {"invalid json", `{`, "2.18.6", ""}, + {"invalid constraint", `{"message":"hi","versions":"???"}`, "2.18.6", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := bannerMessage([]byte(c.raw), c.version); 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..6de8a86d 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -95,7 +95,10 @@ func main() { checkForLegacyName() - if err := app.Run(ctx, os.Args); err != nil { + bannerCh := fetchBanner(ctx) + err := app.Run(ctx, os.Args) + printBanner(bannerCh) + 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 From 8ede20d3595e647bb2c7d26249321e891a51fb5c Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 11 Sep 2026 16:51:46 -0400 Subject: [PATCH 2/9] banner.json is a list: every notice whose constraint matches prints One entry per notice, each with its own versions constraint, so a new-feature announcement for old builds can sit next to an upgrade notice without either having to be removed first. --- banner.json | 5 +---- cmd/lk/banner.go | 52 ++++++++++++++++++++++++++----------------- cmd/lk/banner_test.go | 30 ++++++++++++++++--------- 3 files changed, 53 insertions(+), 34 deletions(-) diff --git a/banner.json b/banner.json index 77abb9cb..fe51488c 100644 --- a/banner.json +++ b/banner.json @@ -1,4 +1 @@ -{ - "message": "", - "versions": "" -} +[] diff --git a/cmd/lk/banner.go b/cmd/lk/banner.go index 3eb91371..12ddf519 100644 --- a/cmd/lk/banner.go +++ b/cmd/lk/banner.go @@ -27,8 +27,8 @@ import ( "github.com/livekit/livekit-cli/v2/pkg/util" ) -// banner.json on main is the notice shown to installed CLIs. Editing that file is -// the whole release process: no build or tag involved. +// 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" type banner struct { @@ -38,11 +38,11 @@ type banner struct { Versions string `json:"versions"` } -// fetchBanner resolves to the banner text for this build, or "" when there is +// fetchBanner resolves to the notices for this build, or nothing when there are // none or the fetch fails. It never delays the command: main prints whatever has // arrived by the time the command finishes and drops the rest. -func fetchBanner(ctx context.Context) <-chan string { - ch := make(chan string, 1) +func fetchBanner(ctx context.Context) <-chan []string { + ch := make(chan []string, 1) go func() { defer close(ch) ctx, cancel := context.WithTimeout(ctx, 2*time.Second) @@ -63,35 +63,47 @@ func fetchBanner(ctx context.Context) <-chan string { if err != nil { return } - ch <- bannerMessage(body, livekitcli.Version) + ch <- bannerMessages(body, livekitcli.Version) }() return ch } -func bannerMessage(raw []byte, version string) string { - var b banner - if json.Unmarshal(raw, &b) != nil || b.Message == "" { - return "" +// 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 } - if b.Versions != "" { - c, err := semver.NewConstraint(b.Versions) - v, verr := semver.NewVersion(version) - if err != nil || verr != nil || !c.Check(v) { - return "" + 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 b.Message + return msgs } -// printBanner shows a fetched banner on an interactive terminal. Non-interactive +// printBanner shows the fetched notices on an interactive terminal. Non-interactive // runs (scripts, pipes) and runs that finished before the fetch never see it. -func printBanner(ch <-chan string) { +func printBanner(ch <-chan []string) { if !out.Interactive() { return } select { - case msg := <-ch: - if msg != "" { + case msgs := <-ch: + for _, msg := range msgs { out.Warnf("\n%s", util.Warn(msg)) } default: diff --git a/cmd/lk/banner_test.go b/cmd/lk/banner_test.go index 12457e24..fa4718cd 100644 --- a/cmd/lk/banner_test.go +++ b/cmd/lk/banner_test.go @@ -1,21 +1,31 @@ package main -import "testing" +import ( + "reflect" + "testing" +) -func TestBannerMessage(t *testing.T) { +func TestBannerMessages(t *testing.T) { cases := []struct { - name, raw, version, want string + name, raw, version string + want []string }{ - {"no constraint shows to everyone", `{"message":"hi"}`, "2.18.6", "hi"}, - {"matching constraint", `{"message":"upgrade","versions":"< 3.0.0"}`, "2.18.6", "upgrade"}, - {"non-matching constraint", `{"message":"upgrade","versions":"< 3.0.0"}`, "3.0.0", ""}, - {"empty message", `{"message":"","versions":""}`, "2.18.6", ""}, - {"invalid json", `{`, "2.18.6", ""}, - {"invalid constraint", `{"message":"hi","versions":"???"}`, "2.18.6", ""}, + {"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 := bannerMessage([]byte(c.raw), c.version); got != c.want { + if got := bannerMessages([]byte(c.raw), c.version); !reflect.DeepEqual(got, c.want) { t.Fatalf("got %q, want %q", got, c.want) } }) From b9e5001e9a6d2dadbb1f5cfaae15edfb1300b181 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 11 Sep 2026 17:05:27 -0400 Subject: [PATCH 3/9] Never show the banner on --json runs Every --json flag binds to a shared jsonOutput destination so the banner, which prints after the command returns, can tell a --json run apart from an interactive one and keep stderr clean for downstream parsers. --- cmd/lk/banner.go | 5 +++-- cmd/lk/docs.go | 7 ++++--- cmd/lk/utils.go | 12 ++++++++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/cmd/lk/banner.go b/cmd/lk/banner.go index 12ddf519..60801f2e 100644 --- a/cmd/lk/banner.go +++ b/cmd/lk/banner.go @@ -96,9 +96,10 @@ func bannerMessages(raw []byte, version string) []string { } // printBanner shows the fetched notices on an interactive terminal. Non-interactive -// runs (scripts, pipes) and runs that finished before the fetch never see it. +// runs (scripts, pipes), --json runs, and runs that finished before the fetch never +// see it. func printBanner(ch <-chan []string) { - if !out.Interactive() { + if !out.Interactive() || jsonOutput { return } select { diff --git a/cmd/lk/docs.go b/cmd/lk/docs.go index 5d9511ec..f19281fd 100644 --- a/cmd/lk/docs.go +++ b/cmd/lk/docs.go @@ -69,9 +69,10 @@ Typical workflow: All output is rendered as markdown.`, Flags: []cli.Flag{ &cli.BoolFlag{ - Name: "json", - Aliases: []string{"j"}, - Usage: "Output as JSON instead of markdown", + Name: "json", + Aliases: []string{"j"}, + Usage: "Output as JSON instead of markdown", + Destination: &jsonOutput, }, &cli.StringFlag{ Name: "server-url", diff --git a/cmd/lk/utils.go b/cmd/lk/utils.go index 6a354b5a..9b57d18b 100644 --- a/cmd/lk/utils.go +++ b/cmd/lk/utils.go @@ -60,10 +60,14 @@ var ( Usage: "`ID` of participant (supports templates)", Required: true, } - jsonFlag = &cli.BoolFlag{ - Name: "json", - Aliases: []string{"j"}, - Usage: "Output as JSON", + // jsonOutput is set by every --json flag so code outside the command (the + // banner) can keep stderr free of anything but the command's own output. + jsonOutput bool + jsonFlag = &cli.BoolFlag{ + Name: "json", + Aliases: []string{"j"}, + Usage: "Output as JSON", + Destination: &jsonOutput, } // quietFlag is global. "silent" is kept as an alias for backwards compatibility with // the former per-command --silent flag; both resolve to the same value and feed the From bab57144de6b93136ebb8d5b32c1619263e1cd83 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 11 Sep 2026 17:11:01 -0400 Subject: [PATCH 4/9] Revert "Never show the banner on --json runs" This reverts commit b9e5001e9a6d2dadbb1f5cfaae15edfb1300b181. --- cmd/lk/banner.go | 5 ++--- cmd/lk/docs.go | 7 +++---- cmd/lk/utils.go | 12 ++++-------- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/cmd/lk/banner.go b/cmd/lk/banner.go index 60801f2e..12ddf519 100644 --- a/cmd/lk/banner.go +++ b/cmd/lk/banner.go @@ -96,10 +96,9 @@ func bannerMessages(raw []byte, version string) []string { } // printBanner shows the fetched notices on an interactive terminal. Non-interactive -// runs (scripts, pipes), --json runs, and runs that finished before the fetch never -// see it. +// runs (scripts, pipes) and runs that finished before the fetch never see it. func printBanner(ch <-chan []string) { - if !out.Interactive() || jsonOutput { + if !out.Interactive() { return } select { diff --git a/cmd/lk/docs.go b/cmd/lk/docs.go index f19281fd..5d9511ec 100644 --- a/cmd/lk/docs.go +++ b/cmd/lk/docs.go @@ -69,10 +69,9 @@ Typical workflow: All output is rendered as markdown.`, Flags: []cli.Flag{ &cli.BoolFlag{ - Name: "json", - Aliases: []string{"j"}, - Usage: "Output as JSON instead of markdown", - Destination: &jsonOutput, + Name: "json", + Aliases: []string{"j"}, + Usage: "Output as JSON instead of markdown", }, &cli.StringFlag{ Name: "server-url", diff --git a/cmd/lk/utils.go b/cmd/lk/utils.go index 9b57d18b..6a354b5a 100644 --- a/cmd/lk/utils.go +++ b/cmd/lk/utils.go @@ -60,14 +60,10 @@ var ( Usage: "`ID` of participant (supports templates)", Required: true, } - // jsonOutput is set by every --json flag so code outside the command (the - // banner) can keep stderr free of anything but the command's own output. - jsonOutput bool - jsonFlag = &cli.BoolFlag{ - Name: "json", - Aliases: []string{"j"}, - Usage: "Output as JSON", - Destination: &jsonOutput, + jsonFlag = &cli.BoolFlag{ + Name: "json", + Aliases: []string{"j"}, + Usage: "Output as JSON", } // quietFlag is global. "silent" is kept as an alias for backwards compatibility with // the former per-command --silent flag; both resolve to the same value and feed the From 29ce927ac0ec698e71bda510193de70fca261e5f Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 11 Sep 2026 17:12:26 -0400 Subject: [PATCH 5/9] Fence the banner and print it through Status Status puts it on stderr and honors --quiet; the border sets it apart from the command's own output. --- cmd/lk/banner.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/cmd/lk/banner.go b/cmd/lk/banner.go index 12ddf519..95085bbb 100644 --- a/cmd/lk/banner.go +++ b/cmd/lk/banner.go @@ -19,8 +19,10 @@ import ( "encoding/json" "io" "net/http" + "strings" "time" + "charm.land/lipgloss/v2" "github.com/Masterminds/semver/v3" livekitcli "github.com/livekit/livekit-cli/v2" @@ -95,17 +97,27 @@ func bannerMessages(raw []byte, version string) []string { return msgs } -// printBanner shows the fetched notices on an interactive terminal. Non-interactive -// runs (scripts, pipes) and runs that finished before the fetch never see it. +// printBanner shows the fetched notices on an interactive terminal via Status, so +// they land on stderr and honor --quiet. Non-interactive runs (scripts, pipes) and +// runs that finished before the fetch never see it. func printBanner(ch <-chan []string) { if !out.Interactive() { return } select { case msgs := <-ch: - for _, msg := range msgs { - out.Warnf("\n%s", util.Warn(msg)) + 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. Built here, after the theme is applied. + fence := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(util.Warning()). + Padding(0, 1). + Width(76) + out.Statusf("\n%s", fence.Render(strings.Join(msgs, "\n\n"))) default: } } From a9031a3c6c36d442320df35529d71c0c2df04b6c Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 11 Sep 2026 17:22:37 -0400 Subject: [PATCH 6/9] Show the banner at the top, from the copy cached by the previous run Printing before the command means the notice cannot wait on the network, so each run shows the banner.json cached at ~/.livekit/banner.json by the run before it and refreshes that cache in the background. A notice lands one run after it is published; no command gets slower. --- cmd/lk/banner.go | 87 ++++++++++++++++++++++++++++++++++-------------- cmd/lk/main.go | 4 ++- 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/cmd/lk/banner.go b/cmd/lk/banner.go index 95085bbb..4e4cc390 100644 --- a/cmd/lk/banner.go +++ b/cmd/lk/banner.go @@ -19,6 +19,8 @@ import ( "encoding/json" "io" "net/http" + "os" + "path/filepath" "strings" "time" @@ -33,6 +35,17 @@ import ( // 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 is shown from the copy cached by the previous run and refreshed in +// the background, so it prints before the command without ever delaying it. A +// new notice therefore appears one run after it lands on main. +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 @@ -40,11 +53,11 @@ type banner struct { Versions string `json:"versions"` } -// fetchBanner resolves to the notices for this build, or nothing when there are -// none or the fetch fails. It never delays the command: main prints whatever has -// arrived by the time the command finishes and drops the rest. -func fetchBanner(ctx context.Context) <-chan []string { - ch := make(chan []string, 1) +// fetchBanner resolves to the raw banner.json, or nothing when the fetch fails. +// It never delays the command: main caches whatever has arrived by the time the +// command finishes and drops the rest. +func fetchBanner(ctx context.Context) <-chan []byte { + ch := make(chan []byte, 1) go func() { defer close(ch) ctx, cancel := context.WithTimeout(ctx, 2*time.Second) @@ -65,11 +78,30 @@ func fetchBanner(ctx context.Context) <-chan []string { if err != nil { return } - ch <- bannerMessages(body, livekitcli.Version) + ch <- body }() return ch } +// saveBanner caches the fetched banner.json for the next run, if it has arrived. +func saveBanner(ch <-chan []byte) { + select { + case raw, ok := <-ch: + if !ok { + return + } + path, err := bannerCachePath() + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return + } + _ = os.WriteFile(path, raw, 0600) + default: + } +} + // bannerMessages returns the message of every entry whose constraint matches // version, in file order. func bannerMessages(raw []byte, version string) []string { @@ -97,27 +129,32 @@ func bannerMessages(raw []byte, version string) []string { return msgs } -// printBanner shows the fetched notices on an interactive terminal via Status, so -// they land on stderr and honor --quiet. Non-interactive runs (scripts, pipes) and -// runs that finished before the fetch never see it. -func printBanner(ch <-chan []string) { +// printBanner shows the cached notices for this build on an interactive terminal +// via Status, so they land on stderr and honor --quiet. Non-interactive runs +// (scripts, pipes) never see it. +func printBanner() { if !out.Interactive() { return } - select { - case msgs := <-ch: - 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. Built here, after the theme is applied. - fence := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(util.Warning()). - Padding(0, 1). - Width(76) - out.Statusf("\n%s", fence.Render(strings.Join(msgs, "\n\n"))) - default: + path, err := bannerCachePath() + if err != nil { + return + } + raw, err := os.ReadFile(path) + if err != nil { + return + } + msgs := bannerMessages(raw, 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/main.go b/cmd/lk/main.go index 6de8a86d..9e30de28 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -97,7 +97,7 @@ func main() { bannerCh := fetchBanner(ctx) err := app.Run(ctx, os.Args) - printBanner(bannerCh) + saveBanner(bannerCh) if err != nil { errStyle := lipgloss.NewStyle().Foreground(util.Error()) // Outside the Printer's reach (it may not be initialized yet), so the @@ -156,6 +156,8 @@ func initLogger(ctx context.Context, cmd *cli.Command) (context.Context, error) } util.DetectBackground() + printBanner() + return nil, nil } From 07f76a5d1f8a96f52234572089a3cb32101ade36 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 11 Sep 2026 17:27:45 -0400 Subject: [PATCH 7/9] Fetch the banner before the command so the first run shows it The fetch is awaited with a 1s timeout and the file is cached for an hour, so only the first run each hour pays the round trip. Non-interactive runs skip the fetch entirely. --- cmd/lk/banner.go | 113 ++++++++++++++++++++++------------------------- cmd/lk/main.go | 4 +- 2 files changed, 54 insertions(+), 63 deletions(-) diff --git a/cmd/lk/banner.go b/cmd/lk/banner.go index 4e4cc390..04b9ee80 100644 --- a/cmd/lk/banner.go +++ b/cmd/lk/banner.go @@ -35,9 +35,13 @@ import ( // 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 is shown from the copy cached by the previous run and refreshed in -// the background, so it prints before the command without ever delaying it. A -// new notice therefore appears one run after it lands on main. +// 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 { @@ -53,53 +57,50 @@ type banner struct { Versions string `json:"versions"` } -// fetchBanner resolves to the raw banner.json, or nothing when the fetch fails. -// It never delays the command: main caches whatever has arrived by the time the -// command finishes and drops the rest. -func fetchBanner(ctx context.Context) <-chan []byte { - ch := make(chan []byte, 1) - go func() { - defer close(ch) - ctx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, bannerURL, nil) - if err != nil { - return - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return - } - body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) - if err != nil { - return - } - ch <- body - }() - return ch +// 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 } -// saveBanner caches the fetched banner.json for the next run, if it has arrived. -func saveBanner(ch <-chan []byte) { - select { - case raw, ok := <-ch: - if !ok { - return - } - path, err := bannerCachePath() - if err != nil { - return - } - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return - } - _ = os.WriteFile(path, raw, 0600) - default: +// 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 @@ -129,22 +130,14 @@ func bannerMessages(raw []byte, version string) []string { return msgs } -// printBanner shows the cached notices for this build on an interactive terminal -// via Status, so they land on stderr and honor --quiet. Non-interactive runs -// (scripts, pipes) never see it. -func printBanner() { +// 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 } - path, err := bannerCachePath() - if err != nil { - return - } - raw, err := os.ReadFile(path) - if err != nil { - return - } - msgs := bannerMessages(raw, livekitcli.Version) + msgs := bannerMessages(loadBanner(ctx), livekitcli.Version) if len(msgs) == 0 { return } diff --git a/cmd/lk/main.go b/cmd/lk/main.go index 9e30de28..b6355f39 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -95,9 +95,7 @@ func main() { checkForLegacyName() - bannerCh := fetchBanner(ctx) err := app.Run(ctx, os.Args) - saveBanner(bannerCh) if err != nil { errStyle := lipgloss.NewStyle().Foreground(util.Error()) // Outside the Printer's reach (it may not be initialized yet), so the @@ -156,7 +154,7 @@ func initLogger(ctx context.Context, cmd *cli.Command) (context.Context, error) } util.DetectBackground() - printBanner() + printBanner(ctx) return nil, nil } From 4daab1378299e4dc527869c55aa9dc69d807a514 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 11 Sep 2026 17:35:26 -0400 Subject: [PATCH 8/9] Record downloadedAt in the banner cache instead of trusting mtime The cache is {data, downloadedAt}; a refetch happens once downloadedAt is over an hour old. Sync tools and backups that rewrite mtime no longer force a refetch or hide a stale file. --- cmd/lk/banner.go | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/cmd/lk/banner.go b/cmd/lk/banner.go index 04b9ee80..912eb035 100644 --- a/cmd/lk/banner.go +++ b/cmd/lk/banner.go @@ -57,24 +57,36 @@ type banner struct { 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. +// bannerCache is the on-disk shape of ~/.livekit/banner.json: the fetched file and +// when it was fetched, so freshness does not depend on the file's mtime. +type bannerCache struct { + Data json.RawMessage `json:"data"` + DownloadedAt time.Time `json:"downloadedAt"` +} + +// loadBanner returns banner.json from the cache when it was downloaded within +// bannerTTL, 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 + var cached bannerCache + if raw, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(raw, &cached) + } + if time.Since(cached.DownloadedAt) < bannerTTL { + return cached.Data } raw := fetchBanner(ctx) if raw == nil { - raw, _ = os.ReadFile(path) - return raw + return cached.Data } if os.MkdirAll(filepath.Dir(path), 0700) == nil { - _ = os.WriteFile(path, raw, 0600) + if enc, err := json.Marshal(bannerCache{Data: raw, DownloadedAt: time.Now()}); err == nil { + _ = os.WriteFile(path, enc, 0600) + } } return raw } From a5023e614176f0eb9d9418b5c1e7b94afe40be9b Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 11 Sep 2026 17:42:28 -0400 Subject: [PATCH 9/9] Revert "Record downloadedAt in the banner cache instead of trusting mtime" This reverts commit 4daab1378299e4dc527869c55aa9dc69d807a514. --- cmd/lk/banner.go | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/cmd/lk/banner.go b/cmd/lk/banner.go index 912eb035..04b9ee80 100644 --- a/cmd/lk/banner.go +++ b/cmd/lk/banner.go @@ -57,36 +57,24 @@ type banner struct { Versions string `json:"versions"` } -// bannerCache is the on-disk shape of ~/.livekit/banner.json: the fetched file and -// when it was fetched, so freshness does not depend on the file's mtime. -type bannerCache struct { - Data json.RawMessage `json:"data"` - DownloadedAt time.Time `json:"downloadedAt"` -} - -// loadBanner returns banner.json from the cache when it was downloaded within -// bannerTTL, otherwise from the network, falling back to a stale cache when the -// fetch fails. +// 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 } - var cached bannerCache - if raw, err := os.ReadFile(path); err == nil { - _ = json.Unmarshal(raw, &cached) - } - if time.Since(cached.DownloadedAt) < bannerTTL { - return cached.Data + 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 { - return cached.Data + raw, _ = os.ReadFile(path) + return raw } if os.MkdirAll(filepath.Dir(path), 0700) == nil { - if enc, err := json.Marshal(bannerCache{Data: raw, DownloadedAt: time.Now()}); err == nil { - _ = os.WriteFile(path, enc, 0600) - } + _ = os.WriteFile(path, raw, 0600) } return raw }