-
Notifications
You must be signed in to change notification settings - Fork 141
feat: add machine-friendly output modes to token create #786
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
|
@@ -58,6 +63,8 @@ var ( | |
| optional(roomFlag), | ||
| optional(identityFlag), | ||
| openFlag, | ||
| jsonFlag, | ||
| tokenOnlyFlag, | ||
|
|
||
| &cli.BoolFlag{ | ||
| Name: "create", | ||
|
|
@@ -143,6 +150,8 @@ var ( | |
| Action: createToken, | ||
| Flags: []cli.Flag{ | ||
| optional(roomFlag), | ||
| jsonFlag, | ||
| tokenOnlyFlag, | ||
|
|
||
| &cli.BoolFlag{ | ||
| Name: "create", | ||
|
|
@@ -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") | ||
|
|
@@ -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)) | ||
|
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)) | ||
|
inickt marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
|
|
||
| grant := &auth.VideoGrant{ | ||
|
|
@@ -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)) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ditto
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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") { | ||
|
|
@@ -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 | ||
| } | ||
| 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 | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.