Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions autocomplete/fish_autocomplete
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
108 changes: 93 additions & 15 deletions cmd/lk/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,22 @@ 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",
}

TokenCommands = []*cli.Command{
{
Name: "token",
Expand All @@ -58,6 +63,8 @@ var (
optional(roomFlag),
optional(identityFlag),
openFlag,
jsonFlag,
tokenOnlyFlag,
Comment thread
inickt marked this conversation as resolved.
Outdated

&cli.BoolFlag{
Name: "create",
Expand Down Expand Up @@ -143,6 +150,8 @@ var (
Action: createToken,
Flags: []cli.Flag{
optional(roomFlag),
jsonFlag,
tokenOnlyFlag,

&cli.BoolFlag{
Name: "create",
Expand Down Expand Up @@ -222,6 +231,11 @@ var (
)

func createToken(ctx context.Context, c *cli.Command) error {
outputMode, err := resolveTokenCreateOutputMode(c)
if err != nil {
return err
}

name := c.String("name")
metadata := c.String("metadata")
validFor := c.String("valid-for")
Expand Down Expand Up @@ -254,13 +268,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 outputMode == tokenOutputModeHuman {
fmt.Printf("Using generated participant identity [%s]\n", util.Accented(participant))
Comment thread
inickt marked this conversation as resolved.
Outdated
}
}

room := c.String("room")
if room == "" {
room = util.ExpandTemplate("room-%t")
fmt.Printf("Using generated room name [%s]\n", util.Accented(room))
if outputMode == tokenOutputModeHuman {
fmt.Printf("Using generated room name [%s]\n", util.Accented(room))
Comment thread
inickt marked this conversation as resolved.
Outdated
}
}

grant := &auth.VideoGrant{
Expand Down Expand Up @@ -419,7 +437,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 outputMode == tokenOutputModeHuman {
fmt.Println("valid for (mins): ", int(dur/time.Minute))
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.

Ditto

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.

Note to self: our homebrew formula currently has a smoketest that checks stdOut for several commands; this will need to be updated in next release.

}
at.SetValidFor(dur)
} else {
return err
Expand All @@ -431,13 +451,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(outputMode, 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") {
Expand All @@ -459,3 +482,58 @@ func accessToken(apiKey, apiSecret string, grant *auth.VideoGrant, identity stri
SetIdentity(identity)
return at
}

type tokenOutputMode string

const (
tokenOutputModeHuman tokenOutputMode = "human"
tokenOutputModeJSON tokenOutputMode = "json"
tokenOutputModeTokenOnly tokenOutputMode = "token-only"
)

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 resolveTokenCreateOutputMode(c *cli.Command) (tokenOutputMode, error) {
jsonOutput := c.Bool("json")
tokenOnly := c.Bool("token-only")

if jsonOutput && tokenOnly {
return "", errors.New("cannot combine --json and --token-only")
}

if tokenOnly {
return tokenOutputModeTokenOnly, nil
}
if jsonOutput {
return tokenOutputModeJSON, nil
}
return tokenOutputModeHuman, nil
}

func printTokenCreateOutput(mode tokenOutputMode, out tokenCreateOutput) error {
switch mode {
case tokenOutputModeTokenOnly:
fmt.Println(out.AccessToken)
case tokenOutputModeJSON:
util.PrintJSON(out)
case tokenOutputModeHuman:
fmt.Println("Token grants:")
util.PrintJSON(out.Grants)
fmt.Println()
if out.ProjectURL != "" {
fmt.Println("Project URL:", out.ProjectURL)
}
fmt.Println("Access token:", out.AccessToken)
default:
return fmt.Errorf("unknown token output mode: %s", mode)
}

return nil
}
146 changes: 146 additions & 0 deletions cmd/lk/token_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package main

import (
"context"
"encoding/json"
"io"
"os"
"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 TestResolveTokenCreateOutputMode(t *testing.T) {
cmd := parseTokenOutputFlags(t)
mode, err := resolveTokenCreateOutputMode(cmd)
require.NoError(t, err)
assert.Equal(t, tokenOutputModeHuman, mode)

cmd = parseTokenOutputFlags(t, "--json")
mode, err = resolveTokenCreateOutputMode(cmd)
require.NoError(t, err)
assert.Equal(t, tokenOutputModeJSON, mode)

cmd = parseTokenOutputFlags(t, "--token-only")
mode, err = resolveTokenCreateOutputMode(cmd)
require.NoError(t, err)
assert.Equal(t, tokenOutputModeTokenOnly, mode)

cmd = parseTokenOutputFlags(t, "--json", "--token-only")
_, err = resolveTokenCreateOutputMode(cmd)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot combine --json and --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"},
}

stdout := captureStdout(t, func() {
err := printTokenCreateOutput(tokenOutputModeTokenOnly, out)
require.NoError(t, err)
})
assert.Equal(t, "token-value\n", stdout)

stdout = captureStdout(t, func() {
err := printTokenCreateOutput(tokenOutputModeJSON, out)
require.NoError(t, err)
})
var decoded map[string]any
require.NoError(t, json.Unmarshal([]byte(stdout), &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 = captureStdout(t, func() {
err := printTokenCreateOutput(tokenOutputModeHuman, out)
require.NoError(t, err)
})
assert.Contains(t, stdout, "Token grants:")
assert.Contains(t, stdout, "Project URL: https://example.livekit.cloud")
assert.Contains(t, stdout, "Access token: token-value")
}

func commandHasFlag(cmd *cli.Command, flagName string) bool {
for _, flag := range cmd.Flags {
if slicesContains(flag.Names(), flagName) {
return true
}
}
return false
}

func parseTokenOutputFlags(t *testing.T, args ...string) *cli.Command {
t.Helper()

var parsedCmd *cli.Command
app := &cli.Command{
Name: "lk",
Flags: []cli.Flag{jsonFlag, tokenOnlyFlag},
Action: func(ctx context.Context, cmd *cli.Command) error {
parsedCmd = cmd
return nil
},
}

runArgs := append([]string{"lk"}, args...)
require.NoError(t, app.Run(context.Background(), runArgs))
require.NotNil(t, parsedCmd)
return parsedCmd
}

func captureStdout(t *testing.T, fn func()) string {
t.Helper()

originalStdout := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w

defer func() {
os.Stdout = originalStdout
}()

fn()

require.NoError(t, w.Close())
out, err := io.ReadAll(r)
require.NoError(t, err)
require.NoError(t, r.Close())
return string(out)
}

func slicesContains(items []string, item string) bool {
for _, current := range items {
if strings.EqualFold(current, item) {
return true
}
}
return false
}