feat: add machine-friendly output modes to token create (#786)

* feat: add machine-friendly output modes to token create

* pr feedback
This commit is contained in:
Nick Thompson
2026-03-13 12:59:23 -04:00
committed by GitHub
parent b707d1be92
commit da6a1b2ee9
4 changed files with 200 additions and 18 deletions

View File

@@ -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'

View File

@@ -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
}

107
cmd/lk/token_test.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
fmt.Println(string(txt))
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(txt))
return err
}