From 9c776f2d7d507c4f9e1e456fdfa11f234f298351 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 25 Sep 2025 13:47:22 -0400 Subject: [PATCH 1/7] extensions subcmd --- cmd/extensions.go | 330 +++++++++++++++++++++++++++++++++++++++++ cmd/extensions_test.go | 210 ++++++++++++++++++++++++++ cmd/root.go | 1 + go.mod | 2 + go.sum | 6 +- 5 files changed, 545 insertions(+), 4 deletions(-) create mode 100644 cmd/extensions.go create mode 100644 cmd/extensions_test.go diff --git a/cmd/extensions.go b/cmd/extensions.go new file mode 100644 index 0000000..721532b --- /dev/null +++ b/cmd/extensions.go @@ -0,0 +1,330 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/onkernel/cli/pkg/util" + "github.com/onkernel/kernel-go-sdk" + "github.com/onkernel/kernel-go-sdk/option" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// ExtensionsService defines the subset of the Kernel SDK extension client that we use. +type ExtensionsService interface { + List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.ExtensionListResponse, err error) + Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) (err error) + Download(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *http.Response, err error) + DownloadFromChromeStore(ctx context.Context, query kernel.ExtensionDownloadFromChromeStoreParams, opts ...option.RequestOption) (res *http.Response, err error) + Upload(ctx context.Context, body kernel.ExtensionUploadParams, opts ...option.RequestOption) (res *kernel.ExtensionUploadResponse, err error) +} + +type ExtensionsListInput struct{} + +type ExtensionsDeleteInput struct { + Identifier string + SkipConfirm bool +} + +type ExtensionsDownloadInput struct { + Identifier string + Output string +} + +type ExtensionsDownloadWebStoreInput struct { + URL string + Output string + OS string +} + +type ExtensionsUploadInput struct { + Dir string + Name string +} + +// ExtensionsCmd handles extension operations independent of cobra. +type ExtensionsCmd struct { + extensions ExtensionsService +} + +func (e ExtensionsCmd) List(ctx context.Context, _ ExtensionsListInput) error { + pterm.Info.Println("Fetching extensions...") + items, err := e.extensions.List(ctx) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if items == nil || len(*items) == 0 { + pterm.Info.Println("No extensions found") + return nil + } + rows := pterm.TableData{{"Extension ID", "Name", "Created At", "Size (bytes)", "Last Used At"}} + for _, it := range *items { + name := it.Name + if name == "" { + name = "-" + } + rows = append(rows, []string{ + it.ID, + name, + util.FormatLocal(it.CreatedAt), + fmt.Sprintf("%d", it.SizeBytes), + util.FormatLocal(it.LastUsedAt), + }) + } + printTableNoPad(rows, true) + return nil +} + +func (e ExtensionsCmd) Delete(ctx context.Context, in ExtensionsDeleteInput) error { + if in.Identifier == "" { + pterm.Error.Println("Missing identifier") + return nil + } + + if !in.SkipConfirm { + msg := fmt.Sprintf("Are you sure you want to delete extension '%s'?", in.Identifier) + pterm.DefaultInteractiveConfirm.DefaultText = msg + ok, _ := pterm.DefaultInteractiveConfirm.Show() + if !ok { + pterm.Info.Println("Deletion cancelled") + return nil + } + } + + if err := e.extensions.Delete(ctx, in.Identifier); err != nil { + if util.IsNotFound(err) { + pterm.Info.Printf("Extension '%s' not found\n", in.Identifier) + return nil + } + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Deleted extension: %s\n", in.Identifier) + return nil +} + +func (e ExtensionsCmd) Download(ctx context.Context, in ExtensionsDownloadInput) error { + if in.Identifier == "" { + pterm.Error.Println("Missing identifier") + return nil + } + res, err := e.extensions.Download(ctx, in.Identifier) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + defer res.Body.Close() + if in.Output == "" { + pterm.Error.Println("Missing --to output file path") + _, _ = io.Copy(io.Discard, res.Body) + return nil + } + f, err := os.Create(in.Output) + if err != nil { + pterm.Error.Printf("Failed to create file: %v\n", err) + return nil + } + defer f.Close() + if _, err := io.Copy(f, res.Body); err != nil { + pterm.Error.Printf("Failed to write file: %v\n", err) + return nil + } + pterm.Success.Printf("Saved extension to %s\n", in.Output) + return nil +} + +func (e ExtensionsCmd) DownloadWebStore(ctx context.Context, in ExtensionsDownloadWebStoreInput) error { + if in.URL == "" { + pterm.Error.Println("Missing URL argument") + return nil + } + params := kernel.ExtensionDownloadFromChromeStoreParams{URL: in.URL} + switch in.OS { + case "", string(kernel.ExtensionDownloadFromChromeStoreParamsOsLinux): + // default linux + case string(kernel.ExtensionDownloadFromChromeStoreParamsOsMac): + params.Os = kernel.ExtensionDownloadFromChromeStoreParamsOsMac + case string(kernel.ExtensionDownloadFromChromeStoreParamsOsWin): + params.Os = kernel.ExtensionDownloadFromChromeStoreParamsOsWin + default: + pterm.Error.Println("--os must be one of mac, win, linux") + return nil + } + + res, err := e.extensions.DownloadFromChromeStore(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + defer res.Body.Close() + + if in.Output == "" { + pterm.Error.Println("Missing --to output file path") + _, _ = io.Copy(io.Discard, res.Body) + return nil + } + + // Try to detect if it's JSON error and pretty print it if output ends with .json + // Otherwise just save raw bytes + var bodyBuf bytes.Buffer + if _, err := io.Copy(&bodyBuf, res.Body); err != nil { + pterm.Error.Printf("Failed to read response: %v\n", err) + return nil + } + + if err := os.WriteFile(in.Output, bodyBuf.Bytes(), 0644); err != nil { + pterm.Error.Printf("Failed to write file: %v\n", err) + return nil + } + // If JSON, try to pretty print in place + if json.Valid(bodyBuf.Bytes()) && (len(in.Output) >= 5 && in.Output[len(in.Output)-5:] == ".json") { + var dst bytes.Buffer + if jerr := json.Indent(&dst, bodyBuf.Bytes(), "", " "); jerr == nil { + _ = os.WriteFile(in.Output, dst.Bytes(), 0644) + } + } + + pterm.Success.Printf("Saved extension to %s\n", in.Output) + return nil +} + +func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) error { + if in.Dir == "" { + return fmt.Errorf("missing directory argument") + } + absDir, err := filepath.Abs(in.Dir) + if err != nil { + return fmt.Errorf("failed to resolve directory: %w", err) + } + stat, err := os.Stat(absDir) + if err != nil || !stat.IsDir() { + return fmt.Errorf("directory %s does not exist", absDir) + } + + tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("kernel_ext_%d.zip", time.Now().UnixNano())) + pterm.Info.Println("Zipping extension directory...") + if err := util.ZipDirectory(absDir, tmpFile); err != nil { + pterm.Error.Println("Failed to zip directory") + return err + } + defer os.Remove(tmpFile) + + f, err := os.Open(tmpFile) + if err != nil { + return fmt.Errorf("failed to open temp zip: %w", err) + } + defer f.Close() + + params := kernel.ExtensionUploadParams{File: f} + if in.Name != "" { + params.Name = kernel.Opt(in.Name) + } + item, err := e.extensions.Upload(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + name := item.Name + if name == "" { + name = "-" + } + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"ID", item.ID}) + rows = append(rows, []string{"Name", name}) + rows = append(rows, []string{"Created At", util.FormatLocal(item.CreatedAt)}) + rows = append(rows, []string{"Size (bytes)", fmt.Sprintf("%d", item.SizeBytes)}) + printTableNoPad(rows, true) + return nil +} + +// --- Cobra wiring --- + +var extensionsCmd = &cobra.Command{ + Use: "extensions", + Short: "Manage browser extensions", + Long: "Commands for managing Kernel browser extensions", +} + +var extensionsListCmd = &cobra.Command{ + Use: "list", + Short: "List extensions", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Extensions + e := ExtensionsCmd{extensions: &svc} + return e.List(cmd.Context(), ExtensionsListInput{}) + }, +} + +var extensionsDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an extension by ID or name", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + skip, _ := cmd.Flags().GetBool("yes") + svc := client.Extensions + e := ExtensionsCmd{extensions: &svc} + return e.Delete(cmd.Context(), ExtensionsDeleteInput{Identifier: args[0], SkipConfirm: skip}) + }, +} + +var extensionsDownloadCmd = &cobra.Command{ + Use: "download ", + Short: "Download an extension archive", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + out, _ := cmd.Flags().GetString("to") + svc := client.Extensions + e := ExtensionsCmd{extensions: &svc} + return e.Download(cmd.Context(), ExtensionsDownloadInput{Identifier: args[0], Output: out}) + }, +} + +var extensionsDownloadWebStoreCmd = &cobra.Command{ + Use: "download-web-store ", + Short: "Download an extension from the Chrome Web Store", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + out, _ := cmd.Flags().GetString("to") + osFlag, _ := cmd.Flags().GetString("os") + svc := client.Extensions + e := ExtensionsCmd{extensions: &svc} + return e.DownloadWebStore(cmd.Context(), ExtensionsDownloadWebStoreInput{URL: args[0], Output: out, OS: osFlag}) + }, +} + +var extensionsUploadCmd = &cobra.Command{ + Use: "upload ", + Short: "Upload an unpacked browser extension directory", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + name, _ := cmd.Flags().GetString("name") + svc := client.Extensions + e := ExtensionsCmd{extensions: &svc} + return e.Upload(cmd.Context(), ExtensionsUploadInput{Dir: args[0], Name: name}) + }, +} + +func init() { + extensionsCmd.AddCommand(extensionsListCmd) + extensionsCmd.AddCommand(extensionsDeleteCmd) + extensionsCmd.AddCommand(extensionsDownloadCmd) + extensionsCmd.AddCommand(extensionsDownloadWebStoreCmd) + extensionsCmd.AddCommand(extensionsUploadCmd) + + extensionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + extensionsDownloadCmd.Flags().String("to", "", "Output zip file path") + extensionsDownloadWebStoreCmd.Flags().String("to", "", "Output zip file path for the downloaded archive") + extensionsDownloadWebStoreCmd.Flags().String("os", "", "Target OS: mac, win, or linux (default linux)") + extensionsUploadCmd.Flags().String("name", "", "Optional unique extension name") +} diff --git a/cmd/extensions_test.go b/cmd/extensions_test.go new file mode 100644 index 0000000..a6c80c6 --- /dev/null +++ b/cmd/extensions_test.go @@ -0,0 +1,210 @@ +package cmd + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/onkernel/kernel-go-sdk" + "github.com/onkernel/kernel-go-sdk/option" + "github.com/pterm/pterm" + "github.com/stretchr/testify/assert" +) + +// captureExtensionsOutput sets pterm writers for tests in this file +func captureExtensionsOutput(t *testing.T) *bytes.Buffer { + var buf bytes.Buffer + pterm.SetDefaultOutput(&buf) + pterm.Info.Writer = &buf + pterm.Error.Writer = &buf + pterm.Success.Writer = &buf + pterm.Warning.Writer = &buf + pterm.Debug.Writer = &buf + pterm.Fatal.Writer = &buf + pterm.DefaultTable = *pterm.DefaultTable.WithWriter(&buf) + t.Cleanup(func() { + pterm.SetDefaultOutput(os.Stdout) + pterm.Info.Writer = os.Stdout + pterm.Error.Writer = os.Stdout + pterm.Success.Writer = os.Stdout + pterm.Warning.Writer = os.Stdout + pterm.Debug.Writer = os.Stdout + pterm.Fatal.Writer = os.Stdout + pterm.DefaultTable = *pterm.DefaultTable.WithWriter(os.Stdout) + }) + return &buf +} + +// FakeExtensionsService implements ExtensionsService +type FakeExtensionsService struct { + ListFunc func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ExtensionListResponse, error) + DeleteFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) error + DownloadFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) + DownloadFromChromeStoreFn func(ctx context.Context, query kernel.ExtensionDownloadFromChromeStoreParams, opts ...option.RequestOption) (*http.Response, error) + UploadFunc func(ctx context.Context, body kernel.ExtensionUploadParams, opts ...option.RequestOption) (*kernel.ExtensionUploadResponse, error) +} + +func (f *FakeExtensionsService) List(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ExtensionListResponse, error) { + if f.ListFunc != nil { + return f.ListFunc(ctx, opts...) + } + empty := []kernel.ExtensionListResponse{} + return &empty, nil +} +func (f *FakeExtensionsService) Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) error { + if f.DeleteFunc != nil { + return f.DeleteFunc(ctx, idOrName, opts...) + } + return nil +} +func (f *FakeExtensionsService) Download(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) { + if f.DownloadFunc != nil { + return f.DownloadFunc(ctx, idOrName, opts...) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{}}, nil +} +func (f *FakeExtensionsService) DownloadFromChromeStore(ctx context.Context, query kernel.ExtensionDownloadFromChromeStoreParams, opts ...option.RequestOption) (*http.Response, error) { + if f.DownloadFromChromeStoreFn != nil { + return f.DownloadFromChromeStoreFn(ctx, query, opts...) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{}}, nil +} +func (f *FakeExtensionsService) Upload(ctx context.Context, body kernel.ExtensionUploadParams, opts ...option.RequestOption) (*kernel.ExtensionUploadResponse, error) { + if f.UploadFunc != nil { + return f.UploadFunc(ctx, body, opts...) + } + return &kernel.ExtensionUploadResponse{ID: "e-new", Name: body.Name.Value, CreatedAt: time.Unix(0, 0), SizeBytes: 1}, nil +} + +func TestExtensionsList_Empty(t *testing.T) { + buf := captureExtensionsOutput(t) + fake := &FakeExtensionsService{} + e := ExtensionsCmd{extensions: fake} + _ = e.List(context.Background(), ExtensionsListInput{}) + assert.Contains(t, buf.String(), "No extensions found") +} + +func TestExtensionsList_WithRows(t *testing.T) { + buf := captureExtensionsOutput(t) + created := time.Unix(0, 0) + rows := []kernel.ExtensionListResponse{{ID: "e1", Name: "alpha", CreatedAt: created, SizeBytes: 10}, {ID: "e2", Name: "", CreatedAt: created, SizeBytes: 20}} + fake := &FakeExtensionsService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ExtensionListResponse, error) { + return &rows, nil + }} + e := ExtensionsCmd{extensions: fake} + _ = e.List(context.Background(), ExtensionsListInput{}) + out := buf.String() + assert.Contains(t, out, "e1") + assert.Contains(t, out, "alpha") + assert.Contains(t, out, "e2") +} + +func TestExtensionsDelete_SkipConfirm(t *testing.T) { + buf := captureExtensionsOutput(t) + fake := &FakeExtensionsService{} + e := ExtensionsCmd{extensions: fake} + _ = e.Delete(context.Background(), ExtensionsDeleteInput{Identifier: "e1", SkipConfirm: true}) + assert.Contains(t, buf.String(), "Deleted extension: e1") +} + +func TestExtensionsDelete_NotFound(t *testing.T) { + buf := captureExtensionsOutput(t) + fake := &FakeExtensionsService{DeleteFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) error { + return &kernel.Error{StatusCode: http.StatusNotFound} + }} + e := ExtensionsCmd{extensions: fake} + _ = e.Delete(context.Background(), ExtensionsDeleteInput{Identifier: "missing", SkipConfirm: true}) + assert.Contains(t, buf.String(), "not found") +} + +func TestExtensionsDownload_MissingOutput(t *testing.T) { + buf := captureExtensionsOutput(t) + fake := &FakeExtensionsService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("content")), Header: http.Header{}}, nil + }} + e := ExtensionsCmd{extensions: fake} + _ = e.Download(context.Background(), ExtensionsDownloadInput{Identifier: "e1", Output: ""}) + assert.Contains(t, buf.String(), "Missing --to output file path") +} + +func TestExtensionsDownload_RawSuccess(t *testing.T) { + buf := captureExtensionsOutput(t) + f, err := os.CreateTemp("", "extension-*.zip") + assert.NoError(t, err) + name := f.Name() + _ = f.Close() + defer os.Remove(name) + + content := "hello" + fake := &FakeExtensionsService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(content)), Header: http.Header{}}, nil + }} + e := ExtensionsCmd{extensions: fake} + _ = e.Download(context.Background(), ExtensionsDownloadInput{Identifier: "e1", Output: name}) + + b, readErr := os.ReadFile(name) + assert.NoError(t, readErr) + assert.Equal(t, content, string(b)) + assert.Contains(t, buf.String(), "Saved extension to "+name) +} + +func TestExtensionsDownloadWebStore_Success(t *testing.T) { + buf := captureExtensionsOutput(t) + f, err := os.CreateTemp("", "webstore-*.zip") + assert.NoError(t, err) + name := f.Name() + _ = f.Close() + defer os.Remove(name) + + content := "zip-bytes" + fake := &FakeExtensionsService{DownloadFromChromeStoreFn: func(ctx context.Context, query kernel.ExtensionDownloadFromChromeStoreParams, opts ...option.RequestOption) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(content)), Header: http.Header{}}, nil + }} + e := ExtensionsCmd{extensions: fake} + _ = e.DownloadWebStore(context.Background(), ExtensionsDownloadWebStoreInput{URL: "https://store/link", Output: name, OS: "linux"}) + + b, readErr := os.ReadFile(name) + assert.NoError(t, readErr) + assert.Equal(t, content, string(b)) + assert.Contains(t, buf.String(), "Saved extension to "+name) +} + +func TestExtensionsDownloadWebStore_InvalidOS(t *testing.T) { + buf := captureExtensionsOutput(t) + fake := &FakeExtensionsService{} + e := ExtensionsCmd{extensions: fake} + _ = e.DownloadWebStore(context.Background(), ExtensionsDownloadWebStoreInput{URL: "https://store/link", Output: "x", OS: "freebsd"}) + assert.Contains(t, buf.String(), "--os must be one of mac, win, linux") +} + +func TestExtensionsUpload_Success(t *testing.T) { + buf := captureExtensionsOutput(t) + dir := t.TempDir() + // create a sample file inside dir + err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte("{}"), 0644) + assert.NoError(t, err) + + fake := &FakeExtensionsService{UploadFunc: func(ctx context.Context, body kernel.ExtensionUploadParams, opts ...option.RequestOption) (*kernel.ExtensionUploadResponse, error) { + return &kernel.ExtensionUploadResponse{ID: "e1", Name: "myext", CreatedAt: time.Unix(0, 0), SizeBytes: 10}, nil + }} + e := ExtensionsCmd{extensions: fake} + _ = e.Upload(context.Background(), ExtensionsUploadInput{Dir: dir, Name: "myext"}) + out := buf.String() + assert.Contains(t, out, "ID") + assert.Contains(t, out, "e1") + assert.Contains(t, out, "Name") + assert.Contains(t, out, "myext") +} + +func TestExtensionsUpload_InvalidDir(t *testing.T) { + fake := &FakeExtensionsService{} + e := ExtensionsCmd{extensions: fake} + err := e.Upload(context.Background(), ExtensionsUploadInput{Dir: "/does/not/exist"}) + assert.Error(t, err) +} diff --git a/cmd/root.go b/cmd/root.go index a3ac908..4e0df49 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -127,6 +127,7 @@ func init() { rootCmd.AddCommand(browsersCmd) rootCmd.AddCommand(appCmd) rootCmd.AddCommand(profilesCmd) + rootCmd.AddCommand(extensionsCmd) rootCmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error { // running synchronously so we never slow the command diff --git a/go.mod b/go.mod index 3032472..956b08a 100644 --- a/go.mod +++ b/go.mod @@ -58,3 +58,5 @@ require ( golang.org/x/text v0.24.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/onkernel/kernel-go-sdk => github.com/stainless-sdks/kernel-go v0.0.0-20250924174430-5aa46b24d0e2 diff --git a/go.sum b/go.sum index f9360bc..063c790 100644 --- a/go.sum +++ b/go.sum @@ -91,10 +91,6 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/onkernel/kernel-go-sdk v0.11.0 h1:7KUKHiz5t4jdnNCwA8NM1dTtEYdk/AV/RIe8T/HjJwg= -github.com/onkernel/kernel-go-sdk v0.11.0/go.mod h1:q7wsAf+yjpY+w8jbAMciWCtCM0ZUxiw/5o2MSPTZS9E= -github.com/onkernel/kernel-go-sdk v0.11.1 h1:gTxhXtsXrJcrM7KEobEVXa8mPPtRFMlxQwNqkyoCrDI= -github.com/onkernel/kernel-go-sdk v0.11.1/go.mod h1:q7wsAf+yjpY+w8jbAMciWCtCM0ZUxiw/5o2MSPTZS9E= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -120,6 +116,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stainless-sdks/kernel-go v0.0.0-20250924174430-5aa46b24d0e2 h1:/AWSQVxr0OQ7MOyDShxIUlswl/YfB0pOOOpu2fiqmLA= +github.com/stainless-sdks/kernel-go v0.0.0-20250924174430-5aa46b24d0e2/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From d601e0b381133551420168833e7fa88022d7cdbd Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 25 Sep 2025 14:00:02 -0400 Subject: [PATCH 2/7] unpack extensions --- cmd/extensions.go | 109 ++++++++++++++++++++++++++++++++--------- cmd/extensions_test.go | 61 +++++++++++++---------- pkg/util/zip.go | 60 +++++++++++++++++++++++ 3 files changed, 181 insertions(+), 49 deletions(-) diff --git a/cmd/extensions.go b/cmd/extensions.go index 721532b..4187ca0 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -3,7 +3,6 @@ package cmd import ( "bytes" "context" - "encoding/json" "fmt" "io" "net/http" @@ -121,21 +120,58 @@ func (e ExtensionsCmd) Download(ctx context.Context, in ExtensionsDownloadInput) } defer res.Body.Close() if in.Output == "" { - pterm.Error.Println("Missing --to output file path") + pterm.Error.Println("Missing --to output directory") _, _ = io.Copy(io.Discard, res.Body) return nil } - f, err := os.Create(in.Output) + + outDir, err := filepath.Abs(in.Output) if err != nil { - pterm.Error.Printf("Failed to create file: %v\n", err) + pterm.Error.Printf("Failed to resolve output path: %v\n", err) + _, _ = io.Copy(io.Discard, res.Body) return nil } - defer f.Close() - if _, err := io.Copy(f, res.Body); err != nil { - pterm.Error.Printf("Failed to write file: %v\n", err) + // Create directory if not exists; if exists, ensure empty + if st, err := os.Stat(outDir); err == nil { + if !st.IsDir() { + pterm.Error.Printf("Output path exists and is not a directory: %s\n", outDir) + _, _ = io.Copy(io.Discard, res.Body) + return nil + } + entries, _ := os.ReadDir(outDir) + if len(entries) > 0 { + pterm.Error.Printf("Output directory must be empty: %s\n", outDir) + _, _ = io.Copy(io.Discard, res.Body) + return nil + } + } else { + if err := os.MkdirAll(outDir, 0o755); err != nil { + pterm.Error.Printf("Failed to create output directory: %v\n", err) + _, _ = io.Copy(io.Discard, res.Body) + return nil + } + } + + // Write response to a temp zip, then extract + tmpZip, err := os.CreateTemp("", "kernel-ext-*.zip") + if err != nil { + pterm.Error.Printf("Failed to create temp zip: %v\n", err) + _, _ = io.Copy(io.Discard, res.Body) return nil } - pterm.Success.Printf("Saved extension to %s\n", in.Output) + tmpName := tmpZip.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := io.Copy(tmpZip, res.Body); err != nil { + _ = tmpZip.Close() + pterm.Error.Printf("Failed to read response: %v\n", err) + return nil + } + _ = tmpZip.Close() + if err := util.Unzip(tmpName, outDir); err != nil { + pterm.Error.Printf("Failed to extract zip: %v\n", err) + return nil + } + pterm.Success.Printf("Extracted extension to %s\n", outDir) return nil } @@ -164,32 +200,61 @@ func (e ExtensionsCmd) DownloadWebStore(ctx context.Context, in ExtensionsDownlo defer res.Body.Close() if in.Output == "" { - pterm.Error.Println("Missing --to output file path") + pterm.Error.Println("Missing --to output directory") _, _ = io.Copy(io.Discard, res.Body) return nil } - // Try to detect if it's JSON error and pretty print it if output ends with .json - // Otherwise just save raw bytes + outDir, err := filepath.Abs(in.Output) + if err != nil { + pterm.Error.Printf("Failed to resolve output path: %v\n", err) + _, _ = io.Copy(io.Discard, res.Body) + return nil + } + if st, err := os.Stat(outDir); err == nil { + if !st.IsDir() { + pterm.Error.Printf("Output path exists and is not a directory: %s\n", outDir) + _, _ = io.Copy(io.Discard, res.Body) + return nil + } + entries, _ := os.ReadDir(outDir) + if len(entries) > 0 { + pterm.Error.Printf("Output directory must be empty: %s\n", outDir) + _, _ = io.Copy(io.Discard, res.Body) + return nil + } + } else { + if err := os.MkdirAll(outDir, 0o755); err != nil { + pterm.Error.Printf("Failed to create output directory: %v\n", err) + _, _ = io.Copy(io.Discard, res.Body) + return nil + } + } + + // Save to temp zip then extract var bodyBuf bytes.Buffer if _, err := io.Copy(&bodyBuf, res.Body); err != nil { pterm.Error.Printf("Failed to read response: %v\n", err) return nil } - - if err := os.WriteFile(in.Output, bodyBuf.Bytes(), 0644); err != nil { - pterm.Error.Printf("Failed to write file: %v\n", err) + tmpZip, err := os.CreateTemp("", "kernel-webstore-*.zip") + if err != nil { + pterm.Error.Printf("Failed to create temp zip: %v\n", err) return nil } - // If JSON, try to pretty print in place - if json.Valid(bodyBuf.Bytes()) && (len(in.Output) >= 5 && in.Output[len(in.Output)-5:] == ".json") { - var dst bytes.Buffer - if jerr := json.Indent(&dst, bodyBuf.Bytes(), "", " "); jerr == nil { - _ = os.WriteFile(in.Output, dst.Bytes(), 0644) - } + tmpName := tmpZip.Name() + if _, err := tmpZip.Write(bodyBuf.Bytes()); err != nil { + _ = tmpZip.Close() + pterm.Error.Printf("Failed to write temp zip: %v\n", err) + return nil } - - pterm.Success.Printf("Saved extension to %s\n", in.Output) + _ = tmpZip.Close() + defer os.Remove(tmpName) + if err := util.Unzip(tmpName, outDir); err != nil { + pterm.Error.Printf("Failed to extract zip: %v\n", err) + return nil + } + pterm.Success.Printf("Extracted extension to %s\n", outDir) return nil } diff --git a/cmd/extensions_test.go b/cmd/extensions_test.go index a6c80c6..9a7897d 100644 --- a/cmd/extensions_test.go +++ b/cmd/extensions_test.go @@ -1,6 +1,7 @@ package cmd import ( + "archive/zip" "bytes" "context" "io" @@ -130,49 +131,55 @@ func TestExtensionsDownload_MissingOutput(t *testing.T) { }} e := ExtensionsCmd{extensions: fake} _ = e.Download(context.Background(), ExtensionsDownloadInput{Identifier: "e1", Output: ""}) - assert.Contains(t, buf.String(), "Missing --to output file path") + assert.Contains(t, buf.String(), "Missing --to output directory") } -func TestExtensionsDownload_RawSuccess(t *testing.T) { +func TestExtensionsDownload_ExtractsToDir(t *testing.T) { buf := captureExtensionsOutput(t) - f, err := os.CreateTemp("", "extension-*.zip") - assert.NoError(t, err) - name := f.Name() - _ = f.Close() - defer os.Remove(name) + // Create a small in-memory zip + var zbuf bytes.Buffer + zw := zip.NewWriter(&zbuf) + w, _ := zw.Create("manifest.json") + _, _ = w.Write([]byte("{}")) + _ = zw.Close() - content := "hello" fake := &FakeExtensionsService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) { - return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(content)), Header: http.Header{}}, nil + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(zbuf.Bytes())), Header: http.Header{}}, nil }} e := ExtensionsCmd{extensions: fake} - _ = e.Download(context.Background(), ExtensionsDownloadInput{Identifier: "e1", Output: name}) - b, readErr := os.ReadFile(name) - assert.NoError(t, readErr) - assert.Equal(t, content, string(b)) - assert.Contains(t, buf.String(), "Saved extension to "+name) + outDir := filepath.Join(os.TempDir(), "extdl-test") + _ = os.RemoveAll(outDir) + _ = e.Download(context.Background(), ExtensionsDownloadInput{Identifier: "e1", Output: outDir}) + + // Ensure extracted + _, statErr := os.Stat(filepath.Join(outDir, "manifest.json")) + assert.NoError(t, statErr) + assert.Contains(t, buf.String(), "Extracted extension to "+outDir) + _ = os.RemoveAll(outDir) } -func TestExtensionsDownloadWebStore_Success(t *testing.T) { +func TestExtensionsDownloadWebStore_ExtractsToDir(t *testing.T) { buf := captureExtensionsOutput(t) - f, err := os.CreateTemp("", "webstore-*.zip") - assert.NoError(t, err) - name := f.Name() - _ = f.Close() - defer os.Remove(name) + var zbuf bytes.Buffer + zw := zip.NewWriter(&zbuf) + w, _ := zw.Create("manifest.json") + _, _ = w.Write([]byte("{}")) + _ = zw.Close() - content := "zip-bytes" fake := &FakeExtensionsService{DownloadFromChromeStoreFn: func(ctx context.Context, query kernel.ExtensionDownloadFromChromeStoreParams, opts ...option.RequestOption) (*http.Response, error) { - return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(content)), Header: http.Header{}}, nil + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(zbuf.Bytes())), Header: http.Header{}}, nil }} e := ExtensionsCmd{extensions: fake} - _ = e.DownloadWebStore(context.Background(), ExtensionsDownloadWebStoreInput{URL: "https://store/link", Output: name, OS: "linux"}) - b, readErr := os.ReadFile(name) - assert.NoError(t, readErr) - assert.Equal(t, content, string(b)) - assert.Contains(t, buf.String(), "Saved extension to "+name) + outDir := filepath.Join(os.TempDir(), "webstoredl-test") + _ = os.RemoveAll(outDir) + _ = e.DownloadWebStore(context.Background(), ExtensionsDownloadWebStoreInput{URL: "https://store/link", Output: outDir, OS: "linux"}) + + _, statErr := os.Stat(filepath.Join(outDir, "manifest.json")) + assert.NoError(t, statErr) + assert.Contains(t, buf.String(), "Extracted extension to "+outDir) + _ = os.RemoveAll(outDir) } func TestExtensionsDownloadWebStore_InvalidOS(t *testing.T) { diff --git a/pkg/util/zip.go b/pkg/util/zip.go index bf066aa..468721d 100644 --- a/pkg/util/zip.go +++ b/pkg/util/zip.go @@ -2,6 +2,7 @@ package util import ( "archive/zip" + "fmt" "io" "os" "path/filepath" @@ -116,3 +117,62 @@ func ZipDirectory(srcDir, destZip string) error { return nil } + +// Unzip extracts a zip file to the specified directory +func Unzip(zipFilePath, destDir string) error { + // Open the zip file + reader, err := zip.OpenReader(zipFilePath) + if err != nil { + return fmt.Errorf("failed to open zip file: %w", err) + } + defer reader.Close() + + // Create the destination directory if it doesn't exist + if err := os.MkdirAll(destDir, 0755); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + // Extract each file + for _, file := range reader.File { + // Create the full destination path + destPath := filepath.Join(destDir, file.Name) + + // Check for directory traversal vulnerabilities + if !strings.HasPrefix(destPath, filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", file.Name) + } + + // Handle directories + if file.FileInfo().IsDir() { + if err := os.MkdirAll(destPath, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + continue + } + + // Create the containing directory if it doesn't exist + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("failed to create directory path: %w", err) + } + + // Open the file from the zip + fileReader, err := file.Open() + if err != nil { + return fmt.Errorf("failed to open file in zip: %w", err) + } + defer fileReader.Close() + + // Create the destination file + destFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + return fmt.Errorf("failed to create destination file (file mode %s): %w", file.Mode().String(), err) + } + defer destFile.Close() + + // Copy the contents + if _, err := io.Copy(destFile, fileReader); err != nil { + return fmt.Errorf("failed to extract file: %w", err) + } + } + + return nil +} From f1971a9fa85354e9d769c9a0284ee51de4a49219 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 29 Sep 2025 12:51:22 -0700 Subject: [PATCH 3/7] add extensions option --- cmd/browsers.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cmd/browsers.go b/cmd/browsers.go index 49605d7..5b63129 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "strings" "github.com/onkernel/cli/pkg/util" @@ -73,6 +74,9 @@ type BoolFlag struct { Value bool } +// Regular expression to validate CUID2 identifiers (24 lowercase alphanumeric characters). +var cuidRegex = regexp.MustCompile(`^[a-z0-9]{24}$`) + // Inputs for each command type BrowsersCreateInput struct { PersistenceID string @@ -82,6 +86,7 @@ type BrowsersCreateInput struct { ProfileID string ProfileName string ProfileSaveChanges BoolFlag + Extensions []string } type BrowsersDeleteInput struct { @@ -178,6 +183,23 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } + // Map extensions (IDs or names) into params.Extensions + if len(in.Extensions) > 0 { + for _, ext := range in.Extensions { + val := strings.TrimSpace(ext) + if val == "" { + continue + } + item := kernel.BrowserNewParamsExtension{} + if cuidRegex.MatchString(val) { + item.ID = kernel.Opt(val) + } else { + item.Name = kernel.Opt(val) + } + params.Extensions = append(params.Extensions, item) + } + } + browser, err := b.browsers.New(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -1321,6 +1343,7 @@ func init() { browsersCreateCmd.Flags().String("profile-id", "", "Profile ID to load into the browser session (mutually exclusive with --profile-name)") browsersCreateCmd.Flags().String("profile-name", "", "Profile name to load into the browser session (mutually exclusive with --profile-id)") browsersCreateCmd.Flags().Bool("save-changes", false, "If set, save changes back to the profile when the session ends") + browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)") // Add flags for delete command browsersDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") @@ -1346,6 +1369,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { profileID, _ := cmd.Flags().GetString("profile-id") profileName, _ := cmd.Flags().GetString("profile-name") saveChanges, _ := cmd.Flags().GetBool("save-changes") + extensions, _ := cmd.Flags().GetStringSlice("extension") in := BrowsersCreateInput{ PersistenceID: persistenceID, @@ -1355,6 +1379,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { ProfileID: profileID, ProfileName: profileName, ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, + Extensions: extensions, } svc := client.Browsers From bd2bf5c87ef532461842235f758062a990dff7f3 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 1 Oct 2025 15:49:15 -0700 Subject: [PATCH 4/7] more --- cmd/browsers.go | 160 ++++++++++++++++++++++++++++++++++++++++--- cmd/browsers_test.go | 6 +- go.mod | 2 +- go.sum | 4 +- 4 files changed, 157 insertions(+), 15 deletions(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index 5b63129..1ae4600 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -2,9 +2,12 @@ package cmd import ( "context" + "crypto/rand" "encoding/base64" + "encoding/json" "fmt" "io" + "math/big" "net/http" "os" "path/filepath" @@ -27,6 +30,7 @@ type BrowsersService interface { New(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (res *kernel.BrowserNewResponse, err error) Delete(ctx context.Context, body kernel.BrowserDeleteParams, opts ...option.RequestOption) (err error) DeleteByID(ctx context.Context, id string, opts ...option.RequestOption) (err error) + UploadExtensions(ctx context.Context, id string, body kernel.BrowserUploadExtensionsParams, opts ...option.RequestOption) (err error) } // BrowserReplaysService defines the subset we use for browser replays. @@ -107,14 +111,34 @@ type BrowsersCmd struct { logs BrowserLogService } -func (b BrowsersCmd) List(ctx context.Context) error { - pterm.Info.Println("Fetching browsers...") +type BrowsersListInput struct { + Output string +} + +func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { + if in.Output != "" && in.Output != "json" { + pterm.Error.Println("unsupported --output value: use 'json'") + return nil + } browsers, err := b.browsers.List(ctx) if err != nil { return util.CleanedUpSdkError{Err: err} } + if in.Output == "json" { + if browsers == nil { + fmt.Println("[]") + return nil + } + bs, err := json.MarshalIndent(*browsers, "", " ") + if err != nil { + return err + } + fmt.Println(string(bs)) + return nil + } + if browsers == nil || len(*browsers) == 0 { pterm.Info.Println("No running or persistent browsers found") return nil @@ -825,6 +849,11 @@ type BrowsersFSWriteFileInput struct { SourcePath string } +type BrowsersExtensionsUploadInput struct { + Identifier string + ExtensionPaths []string +} + func (b BrowsersCmd) FSNewDirectory(ctx context.Context, in BrowsersFSNewDirInput) error { if b.fs == nil { pterm.Error.Println("fs service not available") @@ -1185,6 +1214,99 @@ func (b BrowsersCmd) FSWriteFile(ctx context.Context, in BrowsersFSWriteFileInpu return nil } +func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensionsUploadInput) error { + if b.browsers == nil { + pterm.Error.Println("browsers service not available") + return nil + } + br, err := b.resolveBrowserByIdentifier(ctx, in.Identifier) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if br == nil { + pterm.Error.Printf("Browser '%s' not found\n", in.Identifier) + return nil + } + + if len(in.ExtensionPaths) == 0 { + pterm.Error.Println("no extension paths provided") + return nil + } + + var extensions []kernel.BrowserUploadExtensionsParamsExtension + var tempZipFiles []string + var openFiles []*os.File + + defer func() { + for _, f := range openFiles { + _ = f.Close() + } + for _, zipPath := range tempZipFiles { + _ = os.Remove(zipPath) + } + }() + + for _, extPath := range in.ExtensionPaths { + info, err := os.Stat(extPath) + if err != nil { + pterm.Error.Printf("Failed to stat %s: %v\n", extPath, err) + return nil + } + if !info.IsDir() { + pterm.Error.Printf("Path %s is not a directory\n", extPath) + return nil + } + + extName := generateRandomExtensionName() + tempZipPath := filepath.Join(os.TempDir(), fmt.Sprintf("kernel-ext-%s.zip", extName)) + + pterm.Info.Printf("Zipping %s as %s...\n", extPath, extName) + if err := util.ZipDirectory(extPath, tempZipPath); err != nil { + pterm.Error.Printf("Failed to zip %s: %v\n", extPath, err) + return nil + } + tempZipFiles = append(tempZipFiles, tempZipPath) + + zipFile, err := os.Open(tempZipPath) + if err != nil { + pterm.Error.Printf("Failed to open zip %s: %v\n", tempZipPath, err) + return nil + } + openFiles = append(openFiles, zipFile) + + extensions = append(extensions, kernel.BrowserUploadExtensionsParamsExtension{ + Name: extName, + ZipFile: zipFile, + }) + } + + pterm.Info.Printf("Uploading %d extension(s) to browser %s...\n", len(extensions), br.SessionID) + if err := b.browsers.UploadExtensions(ctx, br.SessionID, kernel.BrowserUploadExtensionsParams{ + Extensions: extensions, + }); err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if len(extensions) == 1 { + pterm.Success.Println("Successfully uploaded 1 extension and restarted Chromium") + } else { + pterm.Success.Printf("Successfully uploaded %d extensions and restarted Chromium\n", len(extensions)) + } + return nil +} + +// generateRandomExtensionName generates a random name matching pattern ^[A-Za-z0-9._-]{1,64}$ +func generateRandomExtensionName() string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" + const nameLen = 16 + result := make([]byte, nameLen) + for i := range result { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) + result[i] = chars[n.Int64()] + } + return string(result) +} + var browsersCmd = &cobra.Command{ Use: "browsers", Short: "Manage browsers", @@ -1204,9 +1326,9 @@ var browsersCreateCmd = &cobra.Command{ } var browsersDeleteCmd = &cobra.Command{ - Use: "delete ", + Use: "delete [ids...]", Short: "Delete a browser", - Args: cobra.ExactArgs(1), + Args: cobra.MinimumNArgs(1), RunE: runBrowsersDelete, } @@ -1218,6 +1340,9 @@ var browsersViewCmd = &cobra.Command{ } func init() { + // list flags + browsersListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + browsersCmd.AddCommand(browsersListCmd) browsersCmd.AddCommand(browsersCreateCmd) browsersCmd.AddCommand(browsersDeleteCmd) @@ -1335,6 +1460,12 @@ func init() { fsRoot.AddCommand(fsNewDir, fsDelDir, fsDelFile, fsDownloadZip, fsFileInfo, fsListFiles, fsMove, fsReadFile, fsSetPerms, fsUpload, fsUploadZip, fsWriteFile) browsersCmd.AddCommand(fsRoot) + // extensions + extensionsRoot := &cobra.Command{Use: "extensions", Short: "Add browser extensions to a running instance"} + extensionsUpload := &cobra.Command{Use: "upload ...", Short: "Upload one or more unpacked extensions and restart Chromium", Args: cobra.MinimumNArgs(2), RunE: runBrowsersExtensionsUpload} + extensionsRoot.AddCommand(extensionsUpload) + browsersCmd.AddCommand(extensionsRoot) + // Add flags for create command browsersCreateCmd.Flags().StringP("persistent-id", "p", "", "Unique identifier for browser session persistence") browsersCreateCmd.Flags().BoolP("stealth", "s", false, "Launch browser in stealth mode to avoid detection") @@ -1355,7 +1486,8 @@ func runBrowsersList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers b := BrowsersCmd{browsers: &svc} - return b.List(cmd.Context()) + out, _ := cmd.Flags().GetString("output") + return b.List(cmd.Context(), BrowsersListInput{Output: out}) } func runBrowsersCreate(cmd *cobra.Command, args []string) error { @@ -1389,14 +1521,17 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { func runBrowsersDelete(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) - - identifier := args[0] skipConfirm, _ := cmd.Flags().GetBool("yes") - in := BrowsersDeleteInput{Identifier: identifier, SkipConfirm: skipConfirm} svc := client.Browsers b := BrowsersCmd{browsers: &svc} - return b.Delete(cmd.Context(), in) + // Iterate all provided identifiers + for _, identifier := range args { + if err := b.Delete(cmd.Context(), BrowsersDeleteInput{Identifier: identifier, SkipConfirm: skipConfirm}); err != nil { + return err + } + } + return nil } func runBrowsersView(cmd *cobra.Command, args []string) error { @@ -1649,6 +1784,13 @@ func runBrowsersFSWriteFile(cmd *cobra.Command, args []string) error { return b.FSWriteFile(cmd.Context(), BrowsersFSWriteFileInput{Identifier: args[0], DestPath: path, Mode: mode, SourcePath: input}) } +func runBrowsersExtensionsUpload(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + b := BrowsersCmd{browsers: &svc} + return b.ExtensionsUpload(cmd.Context(), BrowsersExtensionsUploadInput{Identifier: args[0], ExtensionPaths: args[1:]}) +} + func truncateURL(url string, maxLen int) string { if len(url) <= maxLen { return url diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 061b808..11c2d07 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -97,7 +97,7 @@ func TestBrowsersList_PrintsEmptyMessage(t *testing.T) { }, } b := BrowsersCmd{browsers: fake} - _ = b.List(context.Background()) + _ = b.List(context.Background(), BrowsersListInput{}) out := outBuf.String() assert.Contains(t, out, "No running or persistent browsers found") @@ -130,7 +130,7 @@ func TestBrowsersList_PrintsTableWithRows(t *testing.T) { }, } b := BrowsersCmd{browsers: fake} - _ = b.List(context.Background()) + _ = b.List(context.Background(), BrowsersListInput{}) out := outBuf.String() assert.Contains(t, out, "sess-1") @@ -147,7 +147,7 @@ func TestBrowsersList_PrintsErrorOnFailure(t *testing.T) { }, } b := BrowsersCmd{browsers: fake} - err := b.List(context.Background()) + err := b.List(context.Background(), BrowsersListInput{}) assert.Error(t, err) assert.Contains(t, err.Error(), "list failed") diff --git a/go.mod b/go.mod index c279110..8d3cf21 100644 --- a/go.mod +++ b/go.mod @@ -59,4 +59,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/onkernel/kernel-go-sdk => github.com/stainless-sdks/kernel-go v0.0.0-20250929191717-4fa8c7612d56 +replace github.com/onkernel/kernel-go-sdk => github.com/stainless-sdks/kernel-go v0.0.0-20250930185317-f1e3beca6b4c diff --git a/go.sum b/go.sum index ea82ce1..5807ce6 100644 --- a/go.sum +++ b/go.sum @@ -116,8 +116,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stainless-sdks/kernel-go v0.0.0-20250929191717-4fa8c7612d56 h1:qs/Me9+D3nBi6BpVR6iF4v91cuaae7VR7siSobBKWC8= -github.com/stainless-sdks/kernel-go v0.0.0-20250929191717-4fa8c7612d56/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc= +github.com/stainless-sdks/kernel-go v0.0.0-20250930185317-f1e3beca6b4c h1:a14Qxtl0lnu/WoTSoR4c3H6EyMnCidqOppmgUkhu65c= +github.com/stainless-sdks/kernel-go v0.0.0-20250930185317-f1e3beca6b4c/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From 5aec0a99bdb0941cf3e64b6fe1d7e92dc4627c63 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 1 Oct 2025 15:55:54 -0700 Subject: [PATCH 5/7] readme updates --- README.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/README.md b/README.md index d263680..fd87894 100644 --- a/README.md +++ b/README.md @@ -106,12 +106,14 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). ### App Deployment - `kernel deploy ` - Deploy an app to Kernel + - `--version ` - Specify app version (default: latest) - `--force` - Allow overwriting existing version - `--env `, `-e` - Set environment variables (can be used multiple times) - `--env-file ` - Load environment variables from file (can be used multiple times) - `kernel deploy logs ` - Stream logs for a deployment + - `--follow`, `-f` - Follow logs in real-time (stream continuously) - `--since`, `-s` - How far back to retrieve logs. Duration formats: ns, us, ms, s, m, h (e.g., 5m, 2h, 1h30m). Timestamps also supported: 2006-01-02, 2006-01-02T15:04, 2006-01-02T15:04:05, 2006-01-02T15:04:05.000 - `--with-timestamps`, `-t` - Include timestamps in each log line @@ -234,6 +236,23 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `--mode ` - File mode (octal string) - `--source ` - Local source file path (required) +### Browser Extensions + +- `kernel browsers extensions upload ...` - Ad-hoc upload of one or more unpacked extensions to a running browser instance. + +### Extension Management + +- `kernel extensions list` - List all uploaded extensions +- `kernel extensions upload ` - Upload an unpacked browser extension directory + - `--name ` - Optional unique extension name +- `kernel extensions download ` - Download an extension archive + - `--to ` - Output directory (required) +- `kernel extensions download-web-store ` - Download an extension from the Chrome Web Store + - `--to ` - Output directory (required) + - `--os ` - Target OS: mac, win, or linux (default: linux) +- `kernel extensions delete ` - Delete an extension by ID or name + - `-y, --yes` - Skip confirmation prompt + ## Examples ### Deploy with environment variables @@ -309,6 +328,28 @@ kernel browsers fs upload my-browser --file "local.txt:remote.txt" --dest-dir "/ kernel browsers fs list-files my-browser --path "/tmp" ``` +### Extension management + +```bash +# List all uploaded extensions +kernel extensions list + +# Upload an unpacked extension directory +kernel extensions upload ./my-extension --name my-custom-extension + +# Download an extension from Chrome Web Store +kernel extensions download-web-store "https://chrome.google.com/webstore/detail/extension-id" --to ./downloaded-extension + +# Download a previously uploaded extension +kernel extensions download my-extension-id --to ./my-extension + +# Delete an extension +kernel extensions delete my-extension-name --yes + +# Upload extensions to a running browser instance +kernel browsers extensions upload my-browser ./extension1 ./extension2 +``` + ## Getting Help - `kernel --help` - Show all available commands From 1f36c445d7d1d0195ee706ea02b54b99bdc4e3b0 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 1 Oct 2025 18:04:05 -0700 Subject: [PATCH 6/7] . --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f19a312..1cb90a3 100644 --- a/go.mod +++ b/go.mod @@ -59,4 +59,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/onkernel/kernel-go-sdk => github.com/stainless-sdks/kernel-go v0.0.0-20251001230114-f22a63baf17e +replace github.com/onkernel/kernel-go-sdk => github.com/stainless-sdks/kernel-go v0.0.0-20251001233449-2d91eca4bce0 diff --git a/go.sum b/go.sum index e52ccc2..f4af69a 100644 --- a/go.sum +++ b/go.sum @@ -116,8 +116,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stainless-sdks/kernel-go v0.0.0-20251001230114-f22a63baf17e h1:Xeaw+hqFkObXwh9I0QBdJ4l2SOwcQccmlrlY7p9GX48= -github.com/stainless-sdks/kernel-go v0.0.0-20251001230114-f22a63baf17e/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc= +github.com/stainless-sdks/kernel-go v0.0.0-20251001233449-2d91eca4bce0 h1:IfjadWhvPt5nIJ96ICozWWDMZ+KzzVZvuE4o7L94mdE= +github.com/stainless-sdks/kernel-go v0.0.0-20251001233449-2d91eca4bce0/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From 3e774d5ab341825928b4dc86156cf9578505148c Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Tue, 7 Oct 2025 11:12:04 -0400 Subject: [PATCH 7/7] point sdk to main --- go.mod | 4 +--- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 9319d2a..d3c1670 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/charmbracelet/fang v0.2.0 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/onkernel/kernel-go-sdk v0.14.0 + github.com/onkernel/kernel-go-sdk v0.14.1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 @@ -58,5 +58,3 @@ require ( golang.org/x/text v0.24.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace github.com/onkernel/kernel-go-sdk => github.com/stainless-sdks/kernel-go v0.0.0-20251002005356-49e9894b47bc diff --git a/go.sum b/go.sum index 2c6eb2c..dd9cf02 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,8 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/onkernel/kernel-go-sdk v0.14.1 h1:r4drk5uM1phiXl0dZXhnH1zz5iTmApPC0cGSSiNKbVk= +github.com/onkernel/kernel-go-sdk v0.14.1/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -116,8 +118,6 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stainless-sdks/kernel-go v0.0.0-20251002005356-49e9894b47bc h1:8nQYiQ6tzp3GREkoksUE3zJ0AIZfTqYDxX7hqH2SV7M= -github.com/stainless-sdks/kernel-go v0.0.0-20251002005356-49e9894b47bc/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=