diff --git a/autocomplete/fish_autocomplete b/autocomplete/fish_autocomplete index 4fbef50e..61b7c0d8 100644 --- a/autocomplete/fish_autocomplete +++ b/autocomplete/fish_autocomplete @@ -295,6 +295,8 @@ complete -c lk -n '__fish_seen_subcommand_from create' -f -l allow-source -r -d complete -c lk -n '__fish_seen_subcommand_from create' -f -l identity -s i -r -d 'Unique `ID` of the participant, used with --join' complete -c lk -n '__fish_seen_subcommand_from create' -f -l name -s n -r -d '`NAME` of the participant, used with --join. defaults to identity' complete -c lk -n '__fish_seen_subcommand_from create' -f -l room -s r -r -d '`NAME` of the room to join' +complete -c lk -n '__fish_seen_subcommand_from create' -f -l json -s j -d 'Output as JSON' +complete -c lk -n '__fish_seen_subcommand_from create' -f -l token-only -d 'Output only the access token' complete -c lk -n '__fish_seen_subcommand_from create' -f -l metadata -r -d '`JSON` metadata to encode in the token, will be passed to participant' complete -c lk -n '__fish_seen_subcommand_from create' -f -l attribute -r -d 'set attributes in key=value format, can be used multiple times' complete -c lk -n '__fish_seen_subcommand_from create' -l attribute-file -r -d 'read attributes from a `JSON` file' @@ -315,6 +317,8 @@ complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l allow-source complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l identity -s i -r -d 'Unique `ID` of the participant, used with --join' complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l name -s n -r -d '`NAME` of the participant, used with --join. defaults to identity' complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l room -s r -r -d '`NAME` of the room to join' +complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l json -s j -d 'Output as JSON' +complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l token-only -d 'Output only the access token' complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l room-configuration -r -d 'name of the room configuration to use when creating a room' complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l metadata -r -d '`JSON` metadata to encode in the token, will be passed to participant' complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l attribute -r -d 'set attributes in key=value format, can be used multiple times' diff --git a/cmd/lk/token.go b/cmd/lk/token.go index 999161db..db123cb3 100644 --- a/cmd/lk/token.go +++ b/cmd/lk/token.go @@ -19,6 +19,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "slices" "time" @@ -33,17 +34,30 @@ import ( ) const ( - usageCreate = "Ability to create or delete rooms" - usageList = "Ability to list rooms" - usageJoin = "Ability to join a room (requires --room and --identity)" - usageAdmin = "Ability to moderate a room (requires --room)" - usageEgress = "Ability to interact with Egress services" - usageIngress = "Ability to interact with Ingress services" + usageCreate = "Ability to create or delete rooms" + usageList = "Ability to list rooms" + usageJoin = "Ability to join a room (requires --room and --identity)" + usageAdmin = "Ability to moderate a room (requires --room)" + usageEgress = "Ability to interact with Egress services" + usageIngress = "Ability to interact with Ingress services" usageMetadata = "Ability to update their own name and metadata" usageInference = "Ability to perform inference (AI endpoints)" ) var ( + tokenOnlyFlag = &cli.BoolFlag{ + Name: "token-only", + Usage: "Output only the access token", + } + + tokenOutputMutuallyExclusiveFlags = []cli.MutuallyExclusiveFlags{{ + Flags: [][]cli.Flag{{ + jsonFlag, + }, { + tokenOnlyFlag, + }}, + }} + TokenCommands = []*cli.Command{ { Name: "token", @@ -131,6 +145,7 @@ var ( Usage: "Metadata attached to job dispatched to the agent (ctx.job.metadata)", }, }, + MutuallyExclusiveFlags: tokenOutputMutuallyExclusiveFlags, }, }, }, @@ -217,11 +232,17 @@ var ( Usage: "Additional `VIDEO_GRANT` fields. It'll be merged with other arguments (JSON formatted)", }, }, + MutuallyExclusiveFlags: tokenOutputMutuallyExclusiveFlags, }, } ) func createToken(ctx context.Context, c *cli.Command) error { + tokenOnly := c.Bool("token-only") + jsonOutput := c.Bool("json") + stdout := c.Root().Writer + stderr := c.Root().ErrWriter + name := c.String("name") metadata := c.String("metadata") validFor := c.String("valid-for") @@ -254,13 +275,17 @@ func createToken(ctx context.Context, c *cli.Command) error { participant := c.String("identity") if participant == "" { participant = util.ExpandTemplate("participant-%x") - fmt.Printf("Using generated participant identity [%s]\n", util.Accented(participant)) + if !tokenOnly && !jsonOutput { + fmt.Fprintf(stderr, "Using generated participant identity [%s]\n", util.Accented(participant)) + } } room := c.String("room") if room == "" { room = util.ExpandTemplate("room-%t") - fmt.Printf("Using generated room name [%s]\n", util.Accented(room)) + if !tokenOnly && !jsonOutput { + fmt.Fprintf(stderr, "Using generated room name [%s]\n", util.Accented(room)) + } } grant := &auth.VideoGrant{ @@ -419,7 +444,9 @@ func createToken(ctx context.Context, c *cli.Command) error { at.SetName(name) if validFor != "" { if dur, err := time.ParseDuration(validFor); err == nil { - fmt.Println("valid for (mins): ", int(dur/time.Minute)) + if !tokenOnly && !jsonOutput { + fmt.Fprintf(stderr, "valid for (mins): %d\n", int(dur/time.Minute)) + } at.SetValidFor(dur) } else { return err @@ -431,13 +458,16 @@ func createToken(ctx context.Context, c *cli.Command) error { return err } - fmt.Println("Token grants:") - util.PrintJSON(at.GetGrants()) - fmt.Println() - if project.URL != "" { - fmt.Println("Project URL:", project.URL) + if err = printTokenCreateOutput(stdout, tokenOnly, jsonOutput, tokenCreateOutput{ + AccessToken: token, + ProjectURL: project.URL, + Identity: participant, + Name: name, + Room: room, + Grants: at.GetGrants(), + }); err != nil { + return err } - fmt.Println("Access token:", token) if c.IsSet("open") { switch c.String("open") { @@ -459,3 +489,33 @@ func accessToken(apiKey, apiSecret string, grant *auth.VideoGrant, identity stri SetIdentity(identity) return at } + +type tokenCreateOutput struct { + AccessToken string `json:"access_token"` + ProjectURL string `json:"project_url,omitempty"` + Identity string `json:"identity"` + Name string `json:"name"` + Room string `json:"room"` + Grants *auth.ClaimGrants `json:"grants"` +} + +func printTokenCreateOutput(w io.Writer, tokenOnly, jsonOutput bool, out tokenCreateOutput) error { + switch { + case tokenOnly: + _, _ = fmt.Fprintln(w, out.AccessToken) + case jsonOutput: + return util.PrintJSONTo(w, out) + default: + _, _ = fmt.Fprintln(w, "Token grants:") + if err := util.PrintJSONTo(w, out.Grants); err != nil { + return err + } + _, _ = fmt.Fprintln(w) + if out.ProjectURL != "" { + _, _ = fmt.Fprintln(w, "Project URL:", out.ProjectURL) + } + _, _ = fmt.Fprintln(w, "Access token:", out.AccessToken) + } + + return nil +} diff --git a/cmd/lk/token_test.go b/cmd/lk/token_test.go new file mode 100644 index 00000000..8c39cd58 --- /dev/null +++ b/cmd/lk/token_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/livekit/protocol/auth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestTokenCommandTree(t *testing.T) { + tokenCmd := findCommandByName(TokenCommands, "token") + require.NotNil(t, tokenCmd, "top-level 'token' command must exist") + + createCmd := findCommandByName(tokenCmd.Commands, "create") + require.NotNil(t, createCmd, "'token create' command must exist") + require.NotNil(t, createCmd.Action, "'token create' must have an action") + assert.True(t, commandHasFlag(createCmd, "json"), "'token create' must have --json") + assert.True(t, commandHasFlag(createCmd, "token-only"), "'token create' must have --token-only") + + deprecatedCreateCmd := findCommandByName(TokenCommands, "create-token") + require.NotNil(t, deprecatedCreateCmd, "deprecated 'create-token' command must exist") + assert.True(t, commandHasFlag(deprecatedCreateCmd, "json"), "'create-token' must have --json") + assert.True(t, commandHasFlag(deprecatedCreateCmd, "token-only"), "'create-token' must have --token-only") +} + +func TestTokenOutputFlagsAreMutuallyExclusive(t *testing.T) { + var actionCalled bool + app := &cli.Command{ + Name: "lk", + MutuallyExclusiveFlags: tokenOutputMutuallyExclusiveFlags, + Action: func(ctx context.Context, cmd *cli.Command) error { + actionCalled = true + return nil + }, + } + + err := app.Run(context.Background(), []string{"lk", "--json", "--token-only"}) + require.Error(t, err) + assert.False(t, actionCalled) + assert.Contains(t, err.Error(), "option json cannot be set along with option token-only") +} + +func TestPrintTokenCreateOutput(t *testing.T) { + out := tokenCreateOutput{ + AccessToken: "token-value", + ProjectURL: "https://example.livekit.cloud", + Identity: "test-id", + Name: "test-name", + Room: "test-room", + Grants: &auth.ClaimGrants{Identity: "test-id"}, + } + + var stdout bytes.Buffer + err := printTokenCreateOutput(&stdout, true, false, out) + require.NoError(t, err) + assert.Equal(t, "token-value\n", stdout.String()) + + stdout.Reset() + err = printTokenCreateOutput(&stdout, false, true, out) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal(stdout.Bytes(), &decoded)) + assert.Equal(t, "token-value", decoded["access_token"]) + assert.Equal(t, "https://example.livekit.cloud", decoded["project_url"]) + assert.Equal(t, "test-id", decoded["identity"]) + + stdout.Reset() + err = printTokenCreateOutput(&stdout, false, false, out) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Token grants:") + assert.Contains(t, stdout.String(), "Project URL: https://example.livekit.cloud") + assert.Contains(t, stdout.String(), "Access token: token-value") +} + +func commandHasFlag(cmd *cli.Command, flagName string) bool { + for _, flag := range commandFlags(cmd) { + if slicesContains(flag.Names(), flagName) { + return true + } + } + return false +} + +func commandFlags(cmd *cli.Command) []cli.Flag { + flags := append([]cli.Flag{}, cmd.Flags...) + for _, group := range cmd.MutuallyExclusiveFlags { + for _, path := range group.Flags { + flags = append(flags, path...) + } + } + return flags +} + +func slicesContains(items []string, item string) bool { + for _, current := range items { + if strings.EqualFold(current, item) { + return true + } + } + return false +} diff --git a/pkg/util/json.go b/pkg/util/json.go index ec96359e..159babad 100644 --- a/pkg/util/json.go +++ b/pkg/util/json.go @@ -17,18 +17,29 @@ package util import ( "encoding/json" "fmt" + "io" + "os" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) func PrintJSON(obj any) { + _ = PrintJSONTo(os.Stdout, obj) +} + +func PrintJSONTo(w io.Writer, obj any) error { const indent = " " var txt []byte + var err error if m, ok := obj.(proto.Message); ok { - txt, _ = protojson.MarshalOptions{Indent: indent}.Marshal(m) + txt, err = protojson.MarshalOptions{Indent: indent}.Marshal(m) } else { - txt, _ = json.MarshalIndent(obj, "", indent) + txt, err = json.MarshalIndent(obj, "", indent) + } + if err != nil { + return err } - fmt.Println(string(txt)) + _, err = fmt.Fprintln(w, string(txt)) + return err }