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:
@@ -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'
|
||||
|
||||
@@ -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
107
cmd/lk/token_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user