From 9c24d57061a634277b32459b60ff633fb00191b5 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Thu, 24 Sep 2026 17:19:55 -0400 Subject: [PATCH 1/3] feat: add lk update to upgrade the CLI via its installer --- cmd/lk/main.go | 3 +- cmd/lk/update.go | 75 +++++++++++++++++++++++++++++++++++++++++++ cmd/lk/update_test.go | 38 ++++++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 cmd/lk/update.go create mode 100644 cmd/lk/update_test.go diff --git a/cmd/lk/main.go b/cmd/lk/main.go index ff690538..a2ea22fe 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -113,6 +113,7 @@ Docs: https://docs.livekit.io/intro/basics/cli/`, app.Commands = append(app.Commands, PhoneNumberCommands...) app.Commands = append(app.Commands, ReplayCommands...) app.Commands = append(app.Commands, PerfCommands...) + app.Commands = append(app.Commands, UpdateCommands...) // Register cleanup hook for SIGINT, SIGTERM, SIGQUIT ctx, stop := signal.NotifyContext( @@ -282,7 +283,7 @@ var rootHelpGroups = []struct { {"PROJECTS", []string{"project", "cloud", "app"}}, {"ROOMS AND MEDIA", []string{"room", "token", "dispatch", "egress", "ingress"}}, {"TELEPHONY", []string{"sip", "number"}}, - {"TOOLS", []string{"docs", "perf"}}, + {"TOOLS", []string{"docs", "perf", "update"}}, } // agentHelpSections groups a command's visible subcommands by Category, in diff --git a/cmd/lk/update.go b/cmd/lk/update.go new file mode 100644 index 00000000..bc9f4f6b --- /dev/null +++ b/cmd/lk/update.go @@ -0,0 +1,75 @@ +// Copyright 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" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/util" +) + +var UpdateCommands = []*cli.Command{ + { + Name: "update", + Usage: "Update the CLI to the latest version", + Description: `Detects how lk was installed (Homebrew, winget, or the Linux install script) +and runs that installer's upgrade.`, + Action: updateCLI, + }, +} + +func updateCLI(ctx context.Context, cmd *cli.Command) error { + exe, err := os.Executable() + if err != nil { + return err + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + + args := updateCommandFor(exe) + if args == nil { + return fmt.Errorf("can't tell how %s was installed; reinstall it with the instructions at https://docs.livekit.io/intro/basics/cli/start/", exe) + } + + out.Statusf("Running [%s]", util.Accented(strings.Join(args, " "))) + c := exec.CommandContext(ctx, args[0], args[1:]...) + c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr + return c.Run() +} + +// updateCommandFor maps the resolved path of the running binary to the +// command that upgrades it in place, or nil when the install method is unknown. +// The paths are the ones each installer writes to: Homebrew's Cellar, winget's +// Packages directory, and install-cli.sh's INSTALL_PATH. +func updateCommandFor(exe string) []string { + lower := strings.ToLower(strings.ReplaceAll(exe, `\`, "/")) + switch { + case strings.Contains(lower, "/cellar/livekit-cli/"): + return []string{"brew", "upgrade", "livekit-cli"} + case strings.Contains(lower, "/winget/packages/livekit.livekitcli"): + return []string{"winget", "upgrade", "--id", "LiveKit.LiveKitCLI", "--exact"} + case lower == "/usr/local/bin/lk": + return []string{"bash", "-c", "curl -sSL https://get.livekit.io/cli | bash"} + } + return nil +} diff --git a/cmd/lk/update_test.go b/cmd/lk/update_test.go new file mode 100644 index 00000000..54f9fb02 --- /dev/null +++ b/cmd/lk/update_test.go @@ -0,0 +1,38 @@ +// Copyright 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 ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUpdateCommandFor(t *testing.T) { + cases := []struct { + exe string + want []string + }{ + {"/opt/homebrew/Cellar/livekit-cli/2.18.4/bin/lk", []string{"brew", "upgrade", "livekit-cli"}}, + {"/home/linuxbrew/.linuxbrew/Cellar/livekit-cli/2.18.4/bin/lk", []string{"brew", "upgrade", "livekit-cli"}}, + {`C:\Users\me\AppData\Local\Microsoft\WinGet\Packages\LiveKit.LiveKitCLI_Microsoft.Winget.Source_8wekyb3d8bbwe\lk.exe`, []string{"winget", "upgrade", "--id", "LiveKit.LiveKitCLI", "--exact"}}, + {"/usr/local/bin/lk", []string{"bash", "-c", "curl -sSL https://get.livekit.io/cli | bash"}}, + {"/Users/me/go/bin/lk", nil}, + {"/Users/me/code/livekit-cli/bin/lk", nil}, + } + for _, c := range cases { + assert.Equal(t, c.want, updateCommandFor(c.exe), c.exe) + } +} From aa682ea8c232fa5588297ab99f22b338b1515443 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 25 Sep 2026 08:28:58 -0400 Subject: [PATCH 2/3] feat: add lk can-update to check for a newer CLI release --- cmd/lk/main.go | 2 +- cmd/lk/update.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/cmd/lk/main.go b/cmd/lk/main.go index a2ea22fe..dd55e167 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -283,7 +283,7 @@ var rootHelpGroups = []struct { {"PROJECTS", []string{"project", "cloud", "app"}}, {"ROOMS AND MEDIA", []string{"room", "token", "dispatch", "egress", "ingress"}}, {"TELEPHONY", []string{"sip", "number"}}, - {"TOOLS", []string{"docs", "perf", "update"}}, + {"TOOLS", []string{"docs", "perf", "update", "can-update"}}, } // agentHelpSections groups a command's visible subcommands by Category, in diff --git a/cmd/lk/update.go b/cmd/lk/update.go index bc9f4f6b..0dc2a137 100644 --- a/cmd/lk/update.go +++ b/cmd/lk/update.go @@ -16,17 +16,23 @@ package main import ( "context" + "encoding/json" "fmt" + "net/http" "os" "os/exec" "path/filepath" "strings" + "github.com/Masterminds/semver/v3" "github.com/urfave/cli/v3" + livekitcli "github.com/livekit/livekit-cli/v2" "github.com/livekit/livekit-cli/v2/pkg/util" ) +const latestReleaseURL = "https://api.github.com/repos/livekit/livekit-cli/releases/latest" + var UpdateCommands = []*cli.Command{ { Name: "update", @@ -35,6 +41,47 @@ var UpdateCommands = []*cli.Command{ and runs that installer's upgrade.`, Action: updateCLI, }, + { + Name: "can-update", + Usage: "Check whether a newer CLI version is available", + Action: canUpdateCLI, + }, +} + +func canUpdateCLI(ctx context.Context, cmd *cli.Command) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("checking the latest release: %s", resp.Status) + } + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return err + } + latest, err := semver.NewVersion(release.TagName) + if err != nil { + return err + } + current, err := semver.NewVersion(livekitcli.Version) + if err != nil { + return err + } + + if current.LessThan(latest) { + out.Statusf("Yes: %s is available (you have %s). Run [%s] to update.", latest, current, util.Accented("lk update")) + } else { + out.Statusf("No: %s is the latest version.", current) + } + return nil } func updateCLI(ctx context.Context, cmd *cli.Command) error { From 9238e947d92406da90193fc23a3c73398fd1c75e Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 25 Sep 2026 08:36:05 -0400 Subject: [PATCH 3/3] feat: point unknown commands at lk can-update and lk update --- cmd/lk/main.go | 2 ++ cmd/lk/update.go | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/cmd/lk/main.go b/cmd/lk/main.go index dd55e167..f3729227 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -114,6 +114,8 @@ Docs: https://docs.livekit.io/intro/basics/cli/`, app.Commands = append(app.Commands, ReplayCommands...) app.Commands = append(app.Commands, PerfCommands...) app.Commands = append(app.Commands, UpdateCommands...) + app.CommandNotFound = commandNotFound + setCommandNotFound(app.Commands) // Register cleanup hook for SIGINT, SIGTERM, SIGQUIT ctx, stop := signal.NotifyContext( diff --git a/cmd/lk/update.go b/cmd/lk/update.go index 0dc2a137..f4cbfa96 100644 --- a/cmd/lk/update.go +++ b/cmd/lk/update.go @@ -48,6 +48,27 @@ and runs that installer's upgrade.`, }, } +// commandNotFound keeps urfave/cli's default message and exit code 3, and adds +// how to check for and install a newer lk, since the command may be newer than +// this build. +func commandNotFound(_ context.Context, cmd *cli.Command, name string) { + msg := fmt.Sprintf("No help topic for '%v'", name) + if cmd.Suggest { + if suggestion := cli.SuggestCommand(cmd.Commands, name); suggestion != "" { + msg += ". " + suggestion + } + } + msg += "\n\nIf this command is new, your lk may be out of date. Run `lk can-update` to check and `lk update` to update." + cli.HandleExitCoder(cli.Exit(msg, 3)) +} + +func setCommandNotFound(cmds []*cli.Command) { + for _, c := range cmds { + c.CommandNotFound = commandNotFound + setCommandNotFound(c.Commands) + } +} + func canUpdateCLI(ctx context.Context, cmd *cli.Command) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil) if err != nil {