Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ To run your own, see [biga-sh/docker](https://github.com/biga-sh/docker).
| `biga list` | List known sessions and whether they are live |
| `biga open [dir]` | Open a folder's session in the browser |
| `biga stop [dir]` | Stop a folder's session, or every session with `--all` |
| `biga extensions` | Create, list, or revoke checkpoint feed credentials |

## Configuration

Expand Down
37 changes: 37 additions & 0 deletions internal/api/extensions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package api

import (
"context"
"net/http"
"net/url"
)

type Extension struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
}

type ExtensionGrant struct {
Extension
Token string `json:"token"`
}

func (c *Client) CreateExtension(ctx context.Context, sessionID, ownerToken, name string) (ExtensionGrant, error) {
var grant ExtensionGrant
_, err := c.do(ctx, http.MethodPost, sessionPath(sessionID, "extensions"), map[string]string{"name": name}, &grant, request{attempts: 1, token: ownerToken})
return grant, err
}

func (c *Client) Extensions(ctx context.Context, sessionID, ownerToken string) ([]Extension, error) {
var list struct {
Extensions []Extension `json:"extensions"`
}
_, err := c.do(ctx, http.MethodGet, sessionPath(sessionID, "extensions"), nil, &list, request{attempts: 2, token: ownerToken})
return list.Extensions, err
}

func (c *Client) RevokeExtension(ctx context.Context, sessionID, ownerToken, id string) error {
_, err := c.do(ctx, http.MethodDelete, sessionPath(sessionID, "extensions/"+url.PathEscape(id)), nil, nil, request{attempts: 2, token: ownerToken})
return err
}
2 changes: 1 addition & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Run biga without a command in a terminal to pick one interactively.`,
}
root.PersistentFlags().StringVar(&a.apiURL, "api-url", "",
fmt.Sprintf("server to use (default $%s or %s)", config.EnvAPIURL, config.DefaultAPIURL))
root.AddCommand(a.startCmd(), a.listCmd(), a.statusCmd(), a.stopCmd(), a.openCmd())
root.AddCommand(a.startCmd(), a.listCmd(), a.statusCmd(), a.stopCmd(), a.openCmd(), a.extensionsCmd())
return root
}

Expand Down
113 changes: 113 additions & 0 deletions internal/cli/extensions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package cli

import (
"context"
"errors"
"fmt"

"github.com/spf13/cobra"

"github.com/biga-sh/cli/internal/api"
"github.com/biga-sh/cli/internal/session"
)

func (a *app) extensionsCmd() *cobra.Command {
var dir string
var createJSON, listJSON bool
cmd := &cobra.Command{
Use: "extensions",
Short: "Manage checkpoint feed credentials for a directory's session",
}
cmd.PersistentFlags().StringVar(&dir, "dir", "", "broadcast directory (default: the current directory)")
create := &cobra.Command{
Use: "create <name>",
Short: "Create a read-only checkpoint feed token",
Args: cobra.ExactArgs(1),
RunE: a.run(func(ctx context.Context, args []string) error {
rec, client, err := a.extensionSession(dir)
if err != nil {
return err
}
grant, err := client.CreateExtension(ctx, rec.SessionID, rec.Token, args[0])
if err != nil {
return err
}
result := struct {
api.ExtensionGrant
FeedURL string `json:"feedUrl"`
}{ExtensionGrant: grant, FeedURL: rec.APIURL + "/api/sessions/" + rec.SessionID + "/extension-events"}
if createJSON {
return printJSON(result)
}
a.ui.Info("Extension ID: " + result.ID)
a.ui.Info("Feed URL: " + result.FeedURL)
a.ui.Info("Token (shown once): " + result.Token)
return nil
}),
}
create.Flags().BoolVar(&createJSON, "json", false, "print the credential and feed URL as JSON")
list := &cobra.Command{
Use: "list",
Short: "List extensions without revealing tokens",
Args: cobra.NoArgs,
RunE: a.run(func(ctx context.Context, _ []string) error {
rec, client, err := a.extensionSession(dir)
if err != nil {
return err
}
extensions, err := client.Extensions(ctx, rec.SessionID, rec.Token)
if err != nil {
return err
}
if listJSON {
return printJSON(extensions)
}
if len(extensions) == 0 {
a.ui.Info("No extensions for this session.")
return nil
}
rows := make([][]string, 0, len(extensions))
for _, extension := range extensions {
rows = append(rows, []string{extension.ID, extension.Name, extension.CreatedAt})
}
a.ui.Table([]string{"ID", "NAME", "CREATED"}, rows)
return nil
}),
}
list.Flags().BoolVar(&listJSON, "json", false, "print extensions as JSON")
revoke := &cobra.Command{
Use: "revoke <id>",
Short: "Revoke an extension's checkpoint feed token",
Args: cobra.ExactArgs(1),
RunE: a.run(func(ctx context.Context, args []string) error {
rec, client, err := a.extensionSession(dir)
if err != nil {
return err
}
return client.RevokeExtension(ctx, rec.SessionID, rec.Token, args[0])
}),
}
cmd.AddCommand(create, list, revoke)
return cmd
}

func (a *app) extensionSession(dir string) (session.Record, *api.Client, error) {
target, err := targetDir(dir, nil)
if err != nil {
return session.Record{}, nil, err
}
rec, found, err := a.store.Lookup(target)
if err != nil {
return rec, nil, err
}
if !found {
return rec, nil, errors.New("no session for this directory yet; run biga start first")
}
if rec.Token == "" || rec.APIURL == "" {
return rec, nil, errors.New("the saved session has no owner credentials or server URL")
}
if a.apiURL != "" && a.cfg.APIURL != rec.APIURL {
return rec, nil, fmt.Errorf("this session belongs to %s; omit --api-url or use that server", rec.APIURL)
}
return rec, a.client(rec.APIURL), nil
}
68 changes: 68 additions & 0 deletions internal/cli/extensions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package cli

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"

"github.com/biga-sh/cli/internal/session"
"github.com/biga-sh/cli/internal/ui"
)

func TestExtensionCommandsUseSavedSessionOwner(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer synthetic-owner" {
t.Error("request did not use the saved owner credential")
}
if r.Header.Get("X-Biga-Key") != "synthetic-access-key" {
t.Error("request did not include the server access key")
}
switch r.Method + " " + r.URL.Path {
case "POST /api/sessions/sample/extensions":
var body struct{ Name string }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name != "Build monitor" {
t.Error("incorrect extension creation payload")
}
w.WriteHeader(http.StatusCreated)
io.WriteString(w, `{"ok":true,"data":{"id":"extension-id","name":"Build monitor","token":"synthetic-extension-token"}}`)
case "GET /api/sessions/sample/extensions":
io.WriteString(w, `{"ok":true,"data":{"extensions":[{"id":"extension-id","name":"Build monitor"}]}}`)
case "DELETE /api/sessions/sample/extensions/extension-id":
w.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
home, dir := t.TempDir(), t.TempDir()
t.Setenv("BIGA_HOME", home)
t.Setenv("BIGA_ACCESS_KEY", "synthetic-access-key")
t.Setenv("BIGA_NO_UPDATE_CHECK", "1")
if err := session.NewStore(home).Save(session.Record{RootDir: dir, SessionID: "sample", APIURL: server.URL, Token: "synthetic-owner"}); err != nil {
t.Fatal(err)
}
output, err := os.CreateTemp(t.TempDir(), "output")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = output.Close() })
for _, args := range [][]string{{"create", "Build monitor"}, {"list"}, {"revoke", "extension-id"}} {
a := &app{ui: ui.New(output)}
cmd := a.rootCmd()
cmd.SetArgs(append([]string{"extensions", "--dir", dir}, args...))
if err := cmd.ExecuteContext(t.Context()); err != nil {
t.Fatalf("%s: %v", args[0], err)
}
}
a := &app{ui: ui.New(output)}
cmd := a.rootCmd()
cmd.SetArgs([]string{"extensions", "list", "--dir", dir, "--api-url", "https://unrelated.example"})
if err := cmd.ExecuteContext(t.Context()); err == nil || !strings.Contains(err.Error(), "this session belongs to") {
t.Fatalf("expected server override to be rejected, got %v", err)
}
}
Loading