Skip to content

Commit da6a1b2

Browse files
authored
feat: add machine-friendly output modes to token create (#786)
* feat: add machine-friendly output modes to token create * pr feedback
1 parent b707d1b commit da6a1b2

4 files changed

Lines changed: 200 additions & 18 deletions

File tree

autocomplete/fish_autocomplete

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,8 @@ complete -c lk -n '__fish_seen_subcommand_from create' -f -l allow-source -r -d
295295
complete -c lk -n '__fish_seen_subcommand_from create' -f -l identity -s i -r -d 'Unique `ID` of the participant, used with --join'
296296
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'
297297
complete -c lk -n '__fish_seen_subcommand_from create' -f -l room -s r -r -d '`NAME` of the room to join'
298+
complete -c lk -n '__fish_seen_subcommand_from create' -f -l json -s j -d 'Output as JSON'
299+
complete -c lk -n '__fish_seen_subcommand_from create' -f -l token-only -d 'Output only the access token'
298300
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'
299301
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'
300302
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
315317
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'
316318
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'
317319
complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l room -s r -r -d '`NAME` of the room to join'
320+
complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l json -s j -d 'Output as JSON'
321+
complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l token-only -d 'Output only the access token'
318322
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'
319323
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'
320324
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'

cmd/lk/token.go

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"encoding/json"
2020
"errors"
2121
"fmt"
22+
"io"
2223
"os"
2324
"slices"
2425
"time"
@@ -33,17 +34,30 @@ import (
3334
)
3435

3536
const (
36-
usageCreate = "Ability to create or delete rooms"
37-
usageList = "Ability to list rooms"
38-
usageJoin = "Ability to join a room (requires --room and --identity)"
39-
usageAdmin = "Ability to moderate a room (requires --room)"
40-
usageEgress = "Ability to interact with Egress services"
41-
usageIngress = "Ability to interact with Ingress services"
37+
usageCreate = "Ability to create or delete rooms"
38+
usageList = "Ability to list rooms"
39+
usageJoin = "Ability to join a room (requires --room and --identity)"
40+
usageAdmin = "Ability to moderate a room (requires --room)"
41+
usageEgress = "Ability to interact with Egress services"
42+
usageIngress = "Ability to interact with Ingress services"
4243
usageMetadata = "Ability to update their own name and metadata"
4344
usageInference = "Ability to perform inference (AI endpoints)"
4445
)
4546

4647
var (
48+
tokenOnlyFlag = &cli.BoolFlag{
49+
Name: "token-only",
50+
Usage: "Output only the access token",
51+
}
52+
53+
tokenOutputMutuallyExclusiveFlags = []cli.MutuallyExclusiveFlags{{
54+
Flags: [][]cli.Flag{{
55+
jsonFlag,
56+
}, {
57+
tokenOnlyFlag,
58+
}},
59+
}}
60+
4761
TokenCommands = []*cli.Command{
4862
{
4963
Name: "token",
@@ -131,6 +145,7 @@ var (
131145
Usage: "Metadata attached to job dispatched to the agent (ctx.job.metadata)",
132146
},
133147
},
148+
MutuallyExclusiveFlags: tokenOutputMutuallyExclusiveFlags,
134149
},
135150
},
136151
},
@@ -217,11 +232,17 @@ var (
217232
Usage: "Additional `VIDEO_GRANT` fields. It'll be merged with other arguments (JSON formatted)",
218233
},
219234
},
235+
MutuallyExclusiveFlags: tokenOutputMutuallyExclusiveFlags,
220236
},
221237
}
222238
)
223239

224240
func createToken(ctx context.Context, c *cli.Command) error {
241+
tokenOnly := c.Bool("token-only")
242+
jsonOutput := c.Bool("json")
243+
stdout := c.Root().Writer
244+
stderr := c.Root().ErrWriter
245+
225246
name := c.String("name")
226247
metadata := c.String("metadata")
227248
validFor := c.String("valid-for")
@@ -254,13 +275,17 @@ func createToken(ctx context.Context, c *cli.Command) error {
254275
participant := c.String("identity")
255276
if participant == "" {
256277
participant = util.ExpandTemplate("participant-%x")
257-
fmt.Printf("Using generated participant identity [%s]\n", util.Accented(participant))
278+
if !tokenOnly && !jsonOutput {
279+
fmt.Fprintf(stderr, "Using generated participant identity [%s]\n", util.Accented(participant))
280+
}
258281
}
259282

260283
room := c.String("room")
261284
if room == "" {
262285
room = util.ExpandTemplate("room-%t")
263-
fmt.Printf("Using generated room name [%s]\n", util.Accented(room))
286+
if !tokenOnly && !jsonOutput {
287+
fmt.Fprintf(stderr, "Using generated room name [%s]\n", util.Accented(room))
288+
}
264289
}
265290

266291
grant := &auth.VideoGrant{
@@ -419,7 +444,9 @@ func createToken(ctx context.Context, c *cli.Command) error {
419444
at.SetName(name)
420445
if validFor != "" {
421446
if dur, err := time.ParseDuration(validFor); err == nil {
422-
fmt.Println("valid for (mins): ", int(dur/time.Minute))
447+
if !tokenOnly && !jsonOutput {
448+
fmt.Fprintf(stderr, "valid for (mins): %d\n", int(dur/time.Minute))
449+
}
423450
at.SetValidFor(dur)
424451
} else {
425452
return err
@@ -431,13 +458,16 @@ func createToken(ctx context.Context, c *cli.Command) error {
431458
return err
432459
}
433460

434-
fmt.Println("Token grants:")
435-
util.PrintJSON(at.GetGrants())
436-
fmt.Println()
437-
if project.URL != "" {
438-
fmt.Println("Project URL:", project.URL)
461+
if err = printTokenCreateOutput(stdout, tokenOnly, jsonOutput, tokenCreateOutput{
462+
AccessToken: token,
463+
ProjectURL: project.URL,
464+
Identity: participant,
465+
Name: name,
466+
Room: room,
467+
Grants: at.GetGrants(),
468+
}); err != nil {
469+
return err
439470
}
440-
fmt.Println("Access token:", token)
441471

442472
if c.IsSet("open") {
443473
switch c.String("open") {
@@ -459,3 +489,33 @@ func accessToken(apiKey, apiSecret string, grant *auth.VideoGrant, identity stri
459489
SetIdentity(identity)
460490
return at
461491
}
492+
493+
type tokenCreateOutput struct {
494+
AccessToken string `json:"access_token"`
495+
ProjectURL string `json:"project_url,omitempty"`
496+
Identity string `json:"identity"`
497+
Name string `json:"name"`
498+
Room string `json:"room"`
499+
Grants *auth.ClaimGrants `json:"grants"`
500+
}
501+
502+
func printTokenCreateOutput(w io.Writer, tokenOnly, jsonOutput bool, out tokenCreateOutput) error {
503+
switch {
504+
case tokenOnly:
505+
_, _ = fmt.Fprintln(w, out.AccessToken)
506+
case jsonOutput:
507+
return util.PrintJSONTo(w, out)
508+
default:
509+
_, _ = fmt.Fprintln(w, "Token grants:")
510+
if err := util.PrintJSONTo(w, out.Grants); err != nil {
511+
return err
512+
}
513+
_, _ = fmt.Fprintln(w)
514+
if out.ProjectURL != "" {
515+
_, _ = fmt.Fprintln(w, "Project URL:", out.ProjectURL)
516+
}
517+
_, _ = fmt.Fprintln(w, "Access token:", out.AccessToken)
518+
}
519+
520+
return nil
521+
}

cmd/lk/token_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"strings"
8+
"testing"
9+
10+
"github.com/livekit/protocol/auth"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"github.com/urfave/cli/v3"
14+
)
15+
16+
func TestTokenCommandTree(t *testing.T) {
17+
tokenCmd := findCommandByName(TokenCommands, "token")
18+
require.NotNil(t, tokenCmd, "top-level 'token' command must exist")
19+
20+
createCmd := findCommandByName(tokenCmd.Commands, "create")
21+
require.NotNil(t, createCmd, "'token create' command must exist")
22+
require.NotNil(t, createCmd.Action, "'token create' must have an action")
23+
assert.True(t, commandHasFlag(createCmd, "json"), "'token create' must have --json")
24+
assert.True(t, commandHasFlag(createCmd, "token-only"), "'token create' must have --token-only")
25+
26+
deprecatedCreateCmd := findCommandByName(TokenCommands, "create-token")
27+
require.NotNil(t, deprecatedCreateCmd, "deprecated 'create-token' command must exist")
28+
assert.True(t, commandHasFlag(deprecatedCreateCmd, "json"), "'create-token' must have --json")
29+
assert.True(t, commandHasFlag(deprecatedCreateCmd, "token-only"), "'create-token' must have --token-only")
30+
}
31+
32+
func TestTokenOutputFlagsAreMutuallyExclusive(t *testing.T) {
33+
var actionCalled bool
34+
app := &cli.Command{
35+
Name: "lk",
36+
MutuallyExclusiveFlags: tokenOutputMutuallyExclusiveFlags,
37+
Action: func(ctx context.Context, cmd *cli.Command) error {
38+
actionCalled = true
39+
return nil
40+
},
41+
}
42+
43+
err := app.Run(context.Background(), []string{"lk", "--json", "--token-only"})
44+
require.Error(t, err)
45+
assert.False(t, actionCalled)
46+
assert.Contains(t, err.Error(), "option json cannot be set along with option token-only")
47+
}
48+
49+
func TestPrintTokenCreateOutput(t *testing.T) {
50+
out := tokenCreateOutput{
51+
AccessToken: "token-value",
52+
ProjectURL: "https://example.livekit.cloud",
53+
Identity: "test-id",
54+
Name: "test-name",
55+
Room: "test-room",
56+
Grants: &auth.ClaimGrants{Identity: "test-id"},
57+
}
58+
59+
var stdout bytes.Buffer
60+
err := printTokenCreateOutput(&stdout, true, false, out)
61+
require.NoError(t, err)
62+
assert.Equal(t, "token-value\n", stdout.String())
63+
64+
stdout.Reset()
65+
err = printTokenCreateOutput(&stdout, false, true, out)
66+
require.NoError(t, err)
67+
var decoded map[string]any
68+
require.NoError(t, json.Unmarshal(stdout.Bytes(), &decoded))
69+
assert.Equal(t, "token-value", decoded["access_token"])
70+
assert.Equal(t, "https://example.livekit.cloud", decoded["project_url"])
71+
assert.Equal(t, "test-id", decoded["identity"])
72+
73+
stdout.Reset()
74+
err = printTokenCreateOutput(&stdout, false, false, out)
75+
require.NoError(t, err)
76+
assert.Contains(t, stdout.String(), "Token grants:")
77+
assert.Contains(t, stdout.String(), "Project URL: https://example.livekit.cloud")
78+
assert.Contains(t, stdout.String(), "Access token: token-value")
79+
}
80+
81+
func commandHasFlag(cmd *cli.Command, flagName string) bool {
82+
for _, flag := range commandFlags(cmd) {
83+
if slicesContains(flag.Names(), flagName) {
84+
return true
85+
}
86+
}
87+
return false
88+
}
89+
90+
func commandFlags(cmd *cli.Command) []cli.Flag {
91+
flags := append([]cli.Flag{}, cmd.Flags...)
92+
for _, group := range cmd.MutuallyExclusiveFlags {
93+
for _, path := range group.Flags {
94+
flags = append(flags, path...)
95+
}
96+
}
97+
return flags
98+
}
99+
100+
func slicesContains(items []string, item string) bool {
101+
for _, current := range items {
102+
if strings.EqualFold(current, item) {
103+
return true
104+
}
105+
}
106+
return false
107+
}

pkg/util/json.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,29 @@ package util
1717
import (
1818
"encoding/json"
1919
"fmt"
20+
"io"
21+
"os"
2022

2123
"google.golang.org/protobuf/encoding/protojson"
2224
"google.golang.org/protobuf/proto"
2325
)
2426

2527
func PrintJSON(obj any) {
28+
_ = PrintJSONTo(os.Stdout, obj)
29+
}
30+
31+
func PrintJSONTo(w io.Writer, obj any) error {
2632
const indent = " "
2733
var txt []byte
34+
var err error
2835
if m, ok := obj.(proto.Message); ok {
29-
txt, _ = protojson.MarshalOptions{Indent: indent}.Marshal(m)
36+
txt, err = protojson.MarshalOptions{Indent: indent}.Marshal(m)
3037
} else {
31-
txt, _ = json.MarshalIndent(obj, "", indent)
38+
txt, err = json.MarshalIndent(obj, "", indent)
39+
}
40+
if err != nil {
41+
return err
3242
}
33-
fmt.Println(string(txt))
43+
_, err = fmt.Fprintln(w, string(txt))
44+
return err
3445
}

0 commit comments

Comments
 (0)