diff --git a/autocomplete/fish_autocomplete b/autocomplete/fish_autocomplete index 0361984e..474dbb57 100644 --- a/autocomplete/fish_autocomplete +++ b/autocomplete/fish_autocomplete @@ -229,21 +229,24 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l agent-name -r -d 'Run against an already-running agent instead of spawning one locally. Pass the registered `NAME`, or "" to target the project\'s default agent (the one that auto-joins every room). Requires --scenarios.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy promote status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console daemon simulate help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_lk_no_subcommand' -a 'analytics' -d 'List and inspect LiveKit Cloud analytics sessions' +complete -c lk -n '__fish_seen_subcommand_from analytics' -f -l experimental -d 'Enable experimental features' complete -c lk -n '__fish_seen_subcommand_from analytics' -f -l help -s h -d 'show help' -complete -x -c lk -n '__fish_seen_subcommand_from analytics; and not __fish_seen_subcommand_from list get help h' -a 'list' -d 'List analytics sessions' -complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list' -f -l json -s j -d 'Output as JSON' -complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list' -f -l limit -r -d 'Maximum number of sessions to return' -complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list' -f -l page -r -d 'Page number (starts at 0)' -complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list' -f -l start -r -d 'Start date in `YYYY-MM-DD` format' -complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list' -f -l end -r -d 'End date in `YYYY-MM-DD` format' -complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list' -f -l help -s h -d 'show help' -complete -x -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from analytics; and not __fish_seen_subcommand_from list get help h' -a 'get' -d 'Get analytics session details by session ID' -complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from get' -f -l json -s j -d 'Output as JSON' -complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from get' -f -l help -s h -d 'show help' -complete -x -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from get; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' -complete -x -c lk -n '__fish_seen_subcommand_from analytics; and not __fish_seen_subcommand_from list get help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -x -c lk -n '__fish_seen_subcommand_from analytics; and not __fish_seen_subcommand_from session help h' -a 'session' -d 'List and inspect sessions' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session; and not __fish_seen_subcommand_from list get help h' -a 'list' -d 'List analytics sessions' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from list' -f -l json -s j -d 'Output as JSON' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from list' -f -l limit -r -d 'Maximum number of sessions to return' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from list' -f -l page -r -d 'Page number (starts at 0)' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from list' -f -l start -r -d 'Start date in `YYYY-MM-DD` format' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from list' -f -l end -r -d 'End date in `YYYY-MM-DD` format' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from list' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from list; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -x -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session; and not __fish_seen_subcommand_from list get help h' -a 'get' -d 'Get analytics session details by session ID' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from get' -f -l json -s j -d 'Output as JSON' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from get' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session; and __fish_seen_subcommand_from get; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -x -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from session; and not __fish_seen_subcommand_from list get help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -x -c lk -n '__fish_seen_subcommand_from analytics; and not __fish_seen_subcommand_from session help h' -a 'help' -d 'Shows a list of commands or help for one command' complete -x -c lk -n '__fish_lk_no_subcommand' -a 'cloud' -d 'Interact with LiveKit Cloud services' complete -c lk -n '__fish_seen_subcommand_from cloud' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from cloud; and not __fish_seen_subcommand_from auth help h' -a 'auth' -d 'Authenticate LiveKit Cloud account to link your projects' diff --git a/cmd/lk/agent_private_link_test.go b/cmd/lk/agent_private_link_test.go index 2462a58d..ed23403f 100644 --- a/cmd/lk/agent_private_link_test.go +++ b/cmd/lk/agent_private_link_test.go @@ -7,19 +7,9 @@ import ( lkproto "github.com/livekit/protocol/livekit" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v3" "google.golang.org/protobuf/types/known/timestamppb" ) -func findCommandByName(commands []*cli.Command, name string) *cli.Command { - for _, cmd := range commands { - if cmd != nil && cmd.Name == name { - return cmd - } - } - return nil -} - func TestAgentPrivateLinkCommandTree(t *testing.T) { agentCmd := findCommandByName(AgentCommands, "agent") require.NotNil(t, agentCmd, "top-level 'agent' command must exist") diff --git a/cmd/lk/analytics.go b/cmd/lk/analytics.go index ab32d81f..adb815a5 100644 --- a/cmd/lk/analytics.go +++ b/cmd/lk/analytics.go @@ -41,43 +41,52 @@ const ( var ( AnalyticsCommands = []*cli.Command{ { - Name: "analytics", - Usage: "List and inspect LiveKit Cloud analytics sessions", + Name: "analytics", + Usage: "List and inspect LiveKit Cloud analytics", + Hidden: true, + Flags: []cli.Flag{ + experimentalFlag, + }, Commands: []*cli.Command{ { - Name: "list", - Usage: "List analytics sessions", - Action: listAnalyticsSessions, - Flags: []cli.Flag{ - jsonFlag, - &cli.IntFlag{ - Name: "limit", - Usage: "Maximum number of sessions to return", - Value: defaultAnalyticsLimit, - }, - &cli.IntFlag{ - Name: "page", - Usage: "Page number (starts at 0)", - }, - &cli.StringFlag{ - Name: "start", - Usage: "Start date in `YYYY-MM-DD` format", + Name: "session", + Usage: "List and inspect sessions", Commands: []*cli.Command{ + { + Name: "list", + Usage: "List analytics sessions", + Action: listAnalyticsSessions, + Flags: []cli.Flag{ + jsonFlag, + &cli.IntFlag{ + Name: "limit", + Usage: "Maximum number of sessions to return", + Value: defaultAnalyticsLimit, + }, + &cli.IntFlag{ + Name: "page", + Usage: "Page number (starts at 0)", + }, + &cli.StringFlag{ + Name: "start", + Usage: "Start date in `YYYY-MM-DD` format", + }, + &cli.StringFlag{ + Name: "end", + Usage: "End date in `YYYY-MM-DD` format", + }, + }, }, - &cli.StringFlag{ - Name: "end", - Usage: "End date in `YYYY-MM-DD` format", + { + Name: "get", + Usage: "Get analytics session details by session ID", + ArgsUsage: "SESSION_ID", + Action: getAnalyticsSession, + Flags: []cli.Flag{ + jsonFlag, + }, }, }, }, - { - Name: "get", - Usage: "Get analytics session details by session ID", - ArgsUsage: "SESSION_ID", - Action: getAnalyticsSession, - Flags: []cli.Flag{ - jsonFlag, - }, - }, }, }, } diff --git a/cmd/lk/analytics_test.go b/cmd/lk/analytics_test.go index 7e5e761c..0838fc25 100644 --- a/cmd/lk/analytics_test.go +++ b/cmd/lk/analytics_test.go @@ -30,13 +30,64 @@ func TestAnalyticsCommandTree(t *testing.T) { analyticsCmd := findCommandByName(AnalyticsCommands, "analytics") require.NotNil(t, analyticsCmd, "top-level 'analytics' command must exist") - listCmd := findCommandByName(analyticsCmd.Commands, "list") - require.NotNil(t, listCmd, "'analytics list' command must exist") - require.NotNil(t, listCmd.Action, "'analytics list' must have an action") + sessionCmd := findCommandByName(analyticsCmd.Commands, "session") + require.NotNil(t, sessionCmd, "'analytics session' command must exist") - getCmd := findCommandByName(analyticsCmd.Commands, "get") - require.NotNil(t, getCmd, "'analytics get' command must exist") - require.NotNil(t, getCmd.Action, "'analytics get' must have an action") + listCmd := findCommandByName(sessionCmd.Commands, "list") + require.NotNil(t, listCmd, "'analytics session list' command must exist") + require.NotNil(t, listCmd.Action, "'analytics session list' must have an action") + + getCmd := findCommandByName(sessionCmd.Commands, "get") + require.NotNil(t, getCmd, "'analytics session get' command must exist") + require.NotNil(t, getCmd.Action, "'analytics session get' must have an action") +} + +func TestAnalyticsCommandRequiresExperimentalFlag(t *testing.T) { + analyticsCmd := findCommandByName(AnalyticsCommands, "analytics") + require.NotNil(t, analyticsCmd, "top-level 'analytics' command must exist") + + experimental := findFlagByName(analyticsCmd.Flags, "experimental") + require.NotNil(t, experimental, "'analytics' command must declare an --experimental flag") + + boolFlag, ok := experimental.(*cli.BoolFlag) + require.True(t, ok, "--experimental must be a bool flag") + assert.True(t, boolFlag.Required, "--experimental flag must be required") +} + +// runAnalytics runs the analytics command tree in isolation (as a subcommand of +// a bare root) so flag validation is exercised without touching global CLI state. +func runAnalytics(args ...string) error { + app := &cli.Command{Name: "lk", Commands: AnalyticsCommands} + return app.Run(context.Background(), append([]string{"lk", "analytics"}, args...)) +} + +func TestAnalyticsFailsWithoutExperimentalFlag(t *testing.T) { + // Every leaf must reject invocation when --experimental is omitted, before + // any action (and its network calls) runs. + err := runAnalytics("session", "list") + require.Error(t, err, "'analytics session list' must fail without --experimental") + assert.Contains(t, err.Error(), `Required flag "experimental" not set`) + + err = runAnalytics("session", "get", "sess_123") + require.Error(t, err, "'analytics session get' must fail without --experimental") + assert.Contains(t, err.Error(), `Required flag "experimental" not set`) +} + +func TestAnalyticsFailsWithExperimentalFlagDisabled(t *testing.T) { + // Required only checks presence; an explicit --experimental=false must still + // be rejected by the flag's Validator rather than running the command. + err := runAnalytics("--experimental=false", "session", "list") + require.Error(t, err, "'analytics session list' must fail when --experimental is disabled") + assert.Contains(t, err.Error(), "--experimental must be enabled") +} + +func TestAnalyticsPassesFlagValidationWithExperimentalFlag(t *testing.T) { + // With --experimental set, flag validation passes; any resulting error comes + // from the action itself (e.g. project resolution), not the required flag. + err := runAnalytics("--experimental", "session", "list") + if err != nil { + assert.NotContains(t, err.Error(), `Required flag "experimental" not set`) + } } func TestValidateAnalyticsDateRange(t *testing.T) { diff --git a/cmd/lk/utils.go b/cmd/lk/utils.go index 1406e8ce..378fdf41 100644 --- a/cmd/lk/utils.go +++ b/cmd/lk/utils.go @@ -93,6 +93,19 @@ var ( Name: "install", Usage: "Run installation after creating the application", } + experimentalFlag = &cli.BoolFlag{ + Name: "experimental", + Usage: "Enable experimental features", + Required: true, + // Required only asserts the flag was provided; the Validator rejects an + // explicit --experimental=false so the value must actually be true. + Validator: func(enabled bool) error { + if !enabled { + return errors.New("--experimental must be enabled to use this command") + } + return nil + }, + } // out is the process-wide sink for human-facing CLI output. It is initialized // in main.go's root Before hook from the parsed root command, so all status, diff --git a/cmd/lk/utils_test.go b/cmd/lk/utils_test.go index 6b5b638f..ba7c1bbd 100644 --- a/cmd/lk/utils_test.go +++ b/cmd/lk/utils_test.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "io" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -27,6 +28,29 @@ import ( "github.com/livekit/livekit-cli/v2/pkg/util" ) +// findCommandByName returns the command with the given name from commands, or nil. +func findCommandByName(commands []*cli.Command, name string) *cli.Command { + for _, cmd := range commands { + if cmd != nil && cmd.Name == name { + return cmd + } + } + return nil +} + +// findFlagByName returns the flag matching name (by any of its names) from flags, or nil. +func findFlagByName(flags []cli.Flag, name string) cli.Flag { + for _, flag := range flags { + if flag == nil { + continue + } + if slices.Contains(flag.Names(), name) { + return flag + } + } + return nil +} + // withCapturedAnnounce swaps the package-level Printer for a buffer-backed one for the // duration of the test, returning the buffer that captures status output. The Printer's // nil-safety means we don't have to worry about state from other tests. diff --git a/version.go b/version.go index 4ebba416..469ccdb2 100644 --- a/version.go +++ b/version.go @@ -15,5 +15,5 @@ package livekitcli const ( - Version = "2.18.1" + Version = "2.18.2" )