From db940e887ef0b886d719d9f473d49adc646f8938 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 28 Aug 2025 12:05:22 -0400 Subject: [PATCH 1/5] profiles cmd --- cmd/profiles.go | 217 +++++++++++++++++++++++++++++++++++++++++++ cmd/profiles_test.go | 157 +++++++++++++++++++++++++++++++ cmd/root.go | 1 + go.mod | 2 + go.sum | 4 +- 5 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 cmd/profiles.go create mode 100644 cmd/profiles_test.go diff --git a/cmd/profiles.go b/cmd/profiles.go new file mode 100644 index 0000000..29a803d --- /dev/null +++ b/cmd/profiles.go @@ -0,0 +1,217 @@ +package cmd + +import ( + "context" + "fmt" + + "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" +) + +// ProfilesService defines the subset of the Kernel SDK profile client that we use. +// Mirrors patterns from BrowsersService. +type ProfilesService interface { + Get(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *kernel.Profile, err error) + List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.Profile, err error) + Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) (err error) + New(ctx context.Context, body kernel.ProfileNewParams, opts ...option.RequestOption) (res *kernel.Profile, err error) +} + +type ProfilesGetInput struct { + Identifier string +} + +type ProfilesCreateInput struct { + Name string +} + +type ProfilesDeleteInput struct { + Identifier string + SkipConfirm bool +} + +// ProfilesCmd handles profile operations independent of cobra. +type ProfilesCmd struct { + profiles ProfilesService +} + +func (p ProfilesCmd) List(ctx context.Context) error { + pterm.Info.Println("Fetching profiles...") + items, err := p.profiles.List(ctx) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if items == nil || len(*items) == 0 { + pterm.Info.Println("No profiles found") + return nil + } + rows := pterm.TableData{{"Profile ID", "Name", "Created At", "Updated At", "Last Used At"}} + for _, prof := range *items { + name := prof.Name + if name == "" { + name = "-" + } + rows = append(rows, []string{prof.ID, name, prof.CreatedAt.Format("2006-01-02 15:04:05"), prof.UpdatedAt.Format("2006-01-02 15:04:05"), prof.LastUsedAt.Format("2006-01-02 15:04:05")}) + } + printTableNoPad(rows, true) + return nil +} + +func (p ProfilesCmd) Get(ctx context.Context, in ProfilesGetInput) error { + item, err := p.profiles.Get(ctx, in.Identifier) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if item == nil || item.ID == "" { + pterm.Error.Printf("Profile '%s' not found\n", in.Identifier) + return nil + } + 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", item.CreatedAt.Format("2006-01-02 15:04:05")}) + rows = append(rows, []string{"Updated At", item.UpdatedAt.Format("2006-01-02 15:04:05")}) + printTableNoPad(rows, true) + return nil +} + +func (p ProfilesCmd) Create(ctx context.Context, in ProfilesCreateInput) error { + params := kernel.ProfileNewParams{} + if in.Name != "" { + params.Name = kernel.Opt(in.Name) + } + item, err := p.profiles.New(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", item.CreatedAt.Format("2006-01-02 15:04:05")}) + rows = append(rows, []string{"Updated At", item.UpdatedAt.Format("2006-01-02 15:04:05")}) + printTableNoPad(rows, true) + return nil +} + +func (p ProfilesCmd) Delete(ctx context.Context, in ProfilesDeleteInput) error { + if !in.SkipConfirm { + // Try to resolve for a nicer message; avoid prompting for missing entries + list, err := p.profiles.List(ctx) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + var found *kernel.Profile + if list != nil { + for _, pr := range *list { + if pr.ID == in.Identifier || (pr.Name != "" && pr.Name == in.Identifier) { + cp := pr + found = &cp + break + } + } + } + if found == nil { + pterm.Error.Printf("Profile '%s' not found\n", in.Identifier) + return nil + } + // Confirm + msg := fmt.Sprintf("Are you sure you want to delete profile '%s'?", in.Identifier) + pterm.DefaultInteractiveConfirm.DefaultText = msg + ok, _ := pterm.DefaultInteractiveConfirm.Show() + if !ok { + pterm.Info.Println("Deletion cancelled") + return nil + } + } + + if err := p.profiles.Delete(ctx, in.Identifier); err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Deleted profile: %s\n", in.Identifier) + return nil +} + +// --- Cobra wiring --- + +var profilesCmd = &cobra.Command{ + Use: "profiles", + Short: "Manage profiles", + Long: "Commands for managing Kernel browser profiles", +} + +var profilesListCmd = &cobra.Command{ + Use: "list", + Short: "List profiles", + RunE: runProfilesList, +} + +var profilesGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a profile by ID or name", + Args: cobra.ExactArgs(1), + RunE: runProfilesGet, +} + +var profilesCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new profile", + RunE: runProfilesCreate, +} + +var profilesDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a profile by ID or name", + Args: cobra.ExactArgs(1), + RunE: runProfilesDelete, +} + +func init() { + profilesCmd.AddCommand(profilesListCmd) + profilesCmd.AddCommand(profilesGetCmd) + profilesCmd.AddCommand(profilesCreateCmd) + profilesCmd.AddCommand(profilesDeleteCmd) + + profilesCreateCmd.Flags().String("name", "", "Optional unique profile name") + profilesDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") +} + +func runProfilesList(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Profiles + p := ProfilesCmd{profiles: &svc} + return p.List(cmd.Context()) +} + +func runProfilesGet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Profiles + p := ProfilesCmd{profiles: &svc} + return p.Get(cmd.Context(), ProfilesGetInput{Identifier: args[0]}) +} + +func runProfilesCreate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + name, _ := cmd.Flags().GetString("name") + svc := client.Profiles + p := ProfilesCmd{profiles: &svc} + return p.Create(cmd.Context(), ProfilesCreateInput{Name: name}) +} + +func runProfilesDelete(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + skip, _ := cmd.Flags().GetBool("yes") + svc := client.Profiles + p := ProfilesCmd{profiles: &svc} + return p.Delete(cmd.Context(), ProfilesDeleteInput{Identifier: args[0], SkipConfirm: skip}) +} diff --git a/cmd/profiles_test.go b/cmd/profiles_test.go new file mode 100644 index 0000000..353f83e --- /dev/null +++ b/cmd/profiles_test.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "bytes" + "context" + "errors" + "os" + "testing" + "time" + + "github.com/onkernel/kernel-go-sdk" + "github.com/onkernel/kernel-go-sdk/option" + "github.com/pterm/pterm" + "github.com/stretchr/testify/assert" +) + +// captureProfilesOutput sets pterm writers for tests in this file +func captureProfilesOutput(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 +} + +// FakeProfilesService implements ProfilesService +type FakeProfilesService struct { + GetFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.Profile, error) + ListFunc func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.Profile, error) + DeleteFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) error + NewFunc func(ctx context.Context, body kernel.ProfileNewParams, opts ...option.RequestOption) (*kernel.Profile, error) +} + +func (f *FakeProfilesService) Get(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.Profile, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, idOrName, opts...) + } + return &kernel.Profile{ID: idOrName, CreatedAt: time.Unix(0, 0), UpdatedAt: time.Unix(0, 0)}, nil +} +func (f *FakeProfilesService) List(ctx context.Context, opts ...option.RequestOption) (*[]kernel.Profile, error) { + if f.ListFunc != nil { + return f.ListFunc(ctx, opts...) + } + empty := []kernel.Profile{} + return &empty, nil +} +func (f *FakeProfilesService) Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) error { + if f.DeleteFunc != nil { + return f.DeleteFunc(ctx, idOrName, opts...) + } + return nil +} +func (f *FakeProfilesService) New(ctx context.Context, body kernel.ProfileNewParams, opts ...option.RequestOption) (*kernel.Profile, error) { + if f.NewFunc != nil { + return f.NewFunc(ctx, body, opts...) + } + return &kernel.Profile{ID: "new", Name: body.Name.Value, CreatedAt: time.Unix(0, 0), UpdatedAt: time.Unix(0, 0)}, nil +} + +func TestProfilesList_Empty(t *testing.T) { + buf := captureProfilesOutput(t) + fake := &FakeProfilesService{} + p := ProfilesCmd{profiles: fake} + _ = p.List(context.Background()) + assert.Contains(t, buf.String(), "No profiles found") +} + +func TestProfilesList_WithRows(t *testing.T) { + buf := captureProfilesOutput(t) + created := time.Unix(0, 0) + rows := []kernel.Profile{{ID: "p1", Name: "alpha", CreatedAt: created, UpdatedAt: created}, {ID: "p2", Name: "", CreatedAt: created, UpdatedAt: created}} + fake := &FakeProfilesService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.Profile, error) { return &rows, nil }} + p := ProfilesCmd{profiles: fake} + _ = p.List(context.Background()) + out := buf.String() + assert.Contains(t, out, "p1") + assert.Contains(t, out, "alpha") + assert.Contains(t, out, "p2") +} + +func TestProfilesGet_Success(t *testing.T) { + buf := captureProfilesOutput(t) + fake := &FakeProfilesService{GetFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.Profile, error) { + return &kernel.Profile{ID: "p1", Name: "alpha", CreatedAt: time.Unix(0, 0), UpdatedAt: time.Unix(0, 0)}, nil + }} + p := ProfilesCmd{profiles: fake} + _ = p.Get(context.Background(), ProfilesGetInput{Identifier: "p1"}) + out := buf.String() + assert.Contains(t, out, "ID") + assert.Contains(t, out, "p1") + assert.Contains(t, out, "Name") + assert.Contains(t, out, "alpha") +} + +func TestProfilesGet_Error(t *testing.T) { + fake := &FakeProfilesService{GetFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.Profile, error) { + return nil, errors.New("boom") + }} + p := ProfilesCmd{profiles: fake} + err := p.Get(context.Background(), ProfilesGetInput{Identifier: "x"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} + +func TestProfilesCreate_Success(t *testing.T) { + buf := captureProfilesOutput(t) + fake := &FakeProfilesService{NewFunc: func(ctx context.Context, body kernel.ProfileNewParams, opts ...option.RequestOption) (*kernel.Profile, error) { + return &kernel.Profile{ID: "pnew", Name: body.Name.Value, CreatedAt: time.Unix(0, 0), UpdatedAt: time.Unix(0, 0)}, nil + }} + p := ProfilesCmd{profiles: fake} + _ = p.Create(context.Background(), ProfilesCreateInput{Name: "alpha"}) + out := buf.String() + assert.Contains(t, out, "pnew") + assert.Contains(t, out, "alpha") +} + +func TestProfilesCreate_Error(t *testing.T) { + fake := &FakeProfilesService{NewFunc: func(ctx context.Context, body kernel.ProfileNewParams, opts ...option.RequestOption) (*kernel.Profile, error) { + return nil, errors.New("fail") + }} + p := ProfilesCmd{profiles: fake} + err := p.Create(context.Background(), ProfilesCreateInput{Name: "x"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "fail") +} + +func TestProfilesDelete_ConfirmNotFound(t *testing.T) { + buf := captureProfilesOutput(t) + rows := []kernel.Profile{{ID: "a"}} + fake := &FakeProfilesService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.Profile, error) { return &rows, nil }} + p := ProfilesCmd{profiles: fake} + _ = p.Delete(context.Background(), ProfilesDeleteInput{Identifier: "missing"}) + assert.Contains(t, buf.String(), "not found") +} + +func TestProfilesDelete_SkipConfirm(t *testing.T) { + buf := captureProfilesOutput(t) + fake := &FakeProfilesService{} + p := ProfilesCmd{profiles: fake} + _ = p.Delete(context.Background(), ProfilesDeleteInput{Identifier: "a", SkipConfirm: true}) + assert.Contains(t, buf.String(), "Deleted profile: a") +} diff --git a/cmd/root.go b/cmd/root.go index ee76fa4..a3ac908 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -126,6 +126,7 @@ func init() { rootCmd.AddCommand(invokeCmd) rootCmd.AddCommand(browsersCmd) rootCmd.AddCommand(appCmd) + rootCmd.AddCommand(profilesCmd) 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 cf07c5f..d27486e 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-20250828154810-51461c63b855 diff --git a/go.sum b/go.sum index a713339..f39769f 100644 --- a/go.sum +++ b/go.sum @@ -91,8 +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.10.1-0.20250827184402-40919678c68e h1:5z9iNVA+zyzJZMRn4UGJkhP/ibPZr+/9pxoUK9KqgKk= -github.com/onkernel/kernel-go-sdk v0.10.1-0.20250827184402-40919678c68e/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= @@ -118,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-20250828154810-51461c63b855 h1:X0+cCyCiU3RYx7E7sT6BD0QEFWro0IpapxBAkHJj18U= +github.com/stainless-sdks/kernel-go v0.0.0-20250828154810-51461c63b855/go.mod h1:q7wsAf+yjpY+w8jbAMciWCtCM0ZUxiw/5o2MSPTZS9E= 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 1f6c225d653f7d65ee718bf58ceb98ba18f7b56d Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 3 Sep 2025 15:16:41 -0400 Subject: [PATCH 2/5] udpates --- cmd/app.go | 4 +- cmd/browsers.go | 74 ++++++++++++++++++++++++++-------- cmd/logs.go | 5 ++- cmd/profiles.go | 97 ++++++++++++++++++++++++++++++++++++++++++--- go.mod | 2 +- go.sum | 4 +- pkg/util/timefmt.go | 16 ++++++++ 7 files changed, 173 insertions(+), 29 deletions(-) create mode 100644 pkg/util/timefmt.go diff --git a/cmd/app.go b/cmd/app.go index c7f484b..1c06fdf 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -2,8 +2,8 @@ package cmd import ( "strings" - "time" + "github.com/onkernel/cli/pkg/util" "github.com/onkernel/kernel-go-sdk" "github.com/pterm/pterm" "github.com/samber/lo" @@ -133,7 +133,7 @@ func runAppHistory(cmd *cobra.Command, args []string) error { } for _, dep := range *deployments { - created := dep.CreatedAt.Format(time.RFC3339) + created := util.FormatLocal(dep.CreatedAt) status := string(dep.Status) tableData = append(tableData, []string{ diff --git a/cmd/browsers.go b/cmd/browsers.go index f0cc82e..49d27f9 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -76,10 +76,13 @@ type BoolFlag struct { // Inputs for each command type BrowsersCreateInput struct { - PersistenceID string - TimeoutSeconds int - Stealth BoolFlag - Headless BoolFlag + PersistenceID string + TimeoutSeconds int + Stealth BoolFlag + Headless BoolFlag + ProfileID string + ProfileName string + ProfileSaveChanges BoolFlag } type BrowsersDeleteInput struct { @@ -115,7 +118,7 @@ func (b BrowsersCmd) List(ctx context.Context) error { // Prepare table data tableData := pterm.TableData{ - {"Browser ID", "Created At", "Persistent ID", "CDP WS URL", "Live View URL"}, + {"Browser ID", "Created At", "Persistent ID", "Profile", "CDP WS URL", "Live View URL"}, } for _, browser := range *browsers { @@ -124,10 +127,18 @@ func (b BrowsersCmd) List(ctx context.Context) error { persistentID = browser.Persistence.ID } + profile := "-" + if browser.Profile.Name != "" { + profile = browser.Profile.Name + } else if browser.Profile.ID != "" { + profile = browser.Profile.ID + } + tableData = append(tableData, []string{ browser.SessionID, - browser.CreatedAt.Format("2006-01-02 15:04:05"), + util.FormatLocal(browser.CreatedAt), persistentID, + profile, truncateURL(browser.CdpWsURL, 50), truncateURL(browser.BrowserLiveViewURL, 50), }) @@ -153,6 +164,21 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { params.Headless = kernel.Opt(in.Headless.Value) } + // Validate profile selection: at most one of profile-id or profile-name must be provided + if in.ProfileID != "" && in.ProfileName != "" { + pterm.Error.Println("must specify at most one of --profile-id or --profile-name") + return nil + } else if in.ProfileID != "" || in.ProfileName != "" { + params.Profile = kernel.BrowserNewParamsProfile{ + SaveChanges: kernel.Opt(in.ProfileSaveChanges.Value), + } + if in.ProfileID != "" { + params.Profile.ID = kernel.Opt(in.ProfileID) + } else if in.ProfileName != "" { + params.Profile.Name = kernel.Opt(in.ProfileName) + } + } + browser, err := b.browsers.New(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -169,6 +195,13 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if browser.Persistence.ID != "" { tableData = append(tableData, []string{"Persistent ID", browser.Persistence.ID}) } + if browser.Profile.ID != "" || browser.Profile.Name != "" { + profVal := browser.Profile.Name + if profVal == "" { + profVal = browser.Profile.ID + } + tableData = append(tableData, []string{"Profile", profVal}) + } printTableNoPad(tableData, true) return nil @@ -337,7 +370,7 @@ func (b BrowsersCmd) LogsStream(ctx context.Context, in BrowsersLogsStreamInput) defer stream.Close() for stream.Next() { ev := stream.Current() - pterm.Println(fmt.Sprintf("[%s] %s", ev.Timestamp.Format("2006-01-02 15:04:05"), ev.Message)) + pterm.Println(fmt.Sprintf("[%s] %s", util.FormatLocal(ev.Timestamp), ev.Message)) } if err := stream.Err(); err != nil { return util.CleanedUpSdkError{Err: err} @@ -386,7 +419,7 @@ func (b BrowsersCmd) ReplaysList(ctx context.Context, in BrowsersReplaysListInpu } rows := pterm.TableData{{"Replay ID", "Started At", "Finished At", "View URL"}} for _, r := range *items { - rows = append(rows, []string{r.ReplayID, r.StartedAt.Format("2006-01-02 15:04:05"), r.FinishedAt.Format("2006-01-02 15:04:05"), truncateURL(r.ReplayViewURL, 60)}) + rows = append(rows, []string{r.ReplayID, util.FormatLocal(r.StartedAt), util.FormatLocal(r.FinishedAt), truncateURL(r.ReplayViewURL, 60)}) } printTableNoPad(rows, true) return nil @@ -412,7 +445,7 @@ func (b BrowsersCmd) ReplaysStart(ctx context.Context, in BrowsersReplaysStartIn if err != nil { return util.CleanedUpSdkError{Err: err} } - rows := pterm.TableData{{"Property", "Value"}, {"Replay ID", res.ReplayID}, {"View URL", res.ReplayViewURL}, {"Started At", res.StartedAt.Format("2006-01-02 15:04:05")}} + rows := pterm.TableData{{"Property", "Value"}, {"Replay ID", res.ReplayID}, {"View URL", res.ReplayViewURL}, {"Started At", util.FormatLocal(res.StartedAt)}} printTableNoPad(rows, true) return nil } @@ -597,7 +630,7 @@ func (b BrowsersCmd) ProcessSpawn(ctx context.Context, in BrowsersProcessSpawnIn if err != nil { return util.CleanedUpSdkError{Err: err} } - rows := pterm.TableData{{"Property", "Value"}, {"Process ID", res.ProcessID}, {"PID", fmt.Sprintf("%d", res.Pid)}, {"Started At", res.StartedAt.Format("2006-01-02 15:04:05")}} + rows := pterm.TableData{{"Property", "Value"}, {"Process ID", res.ProcessID}, {"PID", fmt.Sprintf("%d", res.Pid)}, {"Started At", util.FormatLocal(res.StartedAt)}} printTableNoPad(rows, true) return nil } @@ -900,7 +933,7 @@ func (b BrowsersCmd) FSFileInfo(ctx context.Context, in BrowsersFSFileInfoInput) if err != nil { return util.CleanedUpSdkError{Err: err} } - rows := pterm.TableData{{"Property", "Value"}, {"Path", res.Path}, {"Name", res.Name}, {"Mode", res.Mode}, {"IsDir", fmt.Sprintf("%t", res.IsDir)}, {"SizeBytes", fmt.Sprintf("%d", res.SizeBytes)}, {"ModTime", res.ModTime.Format("2006-01-02 15:04:05")}} + rows := pterm.TableData{{"Property", "Value"}, {"Path", res.Path}, {"Name", res.Name}, {"Mode", res.Mode}, {"IsDir", fmt.Sprintf("%t", res.IsDir)}, {"SizeBytes", fmt.Sprintf("%d", res.SizeBytes)}, {"ModTime", util.FormatLocal(res.ModTime)}} printTableNoPad(rows, true) return nil } @@ -928,7 +961,7 @@ func (b BrowsersCmd) FSListFiles(ctx context.Context, in BrowsersFSListFilesInpu } rows := pterm.TableData{{"Mode", "Size", "ModTime", "Name", "Path"}} for _, f := range *res { - rows = append(rows, []string{f.Mode, fmt.Sprintf("%d", f.SizeBytes), f.ModTime.Format("2006-01-02 15:04:05"), f.Name, f.Path}) + rows = append(rows, []string{f.Mode, fmt.Sprintf("%d", f.SizeBytes), util.FormatLocal(f.ModTime), f.Name, f.Path}) } printTableNoPad(rows, true) return nil @@ -1297,6 +1330,9 @@ func init() { browsersCreateCmd.Flags().BoolP("stealth", "s", false, "Launch browser in stealth mode to avoid detection") browsersCreateCmd.Flags().BoolP("headless", "H", false, "Launch browser without GUI access") browsersCreateCmd.Flags().IntP("timeout", "t", 60, "Timeout in seconds for the browser session") + 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") // Add flags for delete command browsersDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") @@ -1319,12 +1355,18 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { stealthVal, _ := cmd.Flags().GetBool("stealth") headlessVal, _ := cmd.Flags().GetBool("headless") timeout, _ := cmd.Flags().GetInt("timeout") + profileID, _ := cmd.Flags().GetString("profile-id") + profileName, _ := cmd.Flags().GetString("profile-name") + saveChanges, _ := cmd.Flags().GetBool("save-changes") in := BrowsersCreateInput{ - PersistenceID: persistenceID, - TimeoutSeconds: timeout, - Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealthVal}, - Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, + PersistenceID: persistenceID, + TimeoutSeconds: timeout, + Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealthVal}, + Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, + ProfileID: profileID, + ProfileName: profileName, + ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, } svc := client.Browsers diff --git a/cmd/logs.go b/cmd/logs.go index cb1a3c4..bd1d083 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "github.com/onkernel/cli/pkg/util" "github.com/onkernel/kernel-go-sdk" "github.com/onkernel/kernel-go-sdk/option" "github.com/pterm/pterm" @@ -83,7 +84,7 @@ func runLogs(cmd *cobra.Command, args []string) error { case "log": logEntry := data.AsLog() if timestamps { - fmt.Printf("%s %s\n", logEntry.Timestamp.Format(time.RFC3339Nano), logEntry.Message) + fmt.Printf("%s %s\n", util.FormatLocal(logEntry.Timestamp), logEntry.Message) } else { fmt.Println(logEntry.Message) } @@ -117,7 +118,7 @@ func runLogs(cmd *cobra.Command, args []string) error { case "log": logEntry := data.AsLog() if timestamps { - fmt.Printf("%s %s\n", logEntry.Timestamp.Format(time.RFC3339Nano), logEntry.Message) + fmt.Printf("%s %s\n", util.FormatLocal(logEntry.Timestamp), logEntry.Message) } else { fmt.Println(logEntry.Message) } diff --git a/cmd/profiles.go b/cmd/profiles.go index 29a803d..77a471e 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -1,8 +1,13 @@ package cmd import ( + "bytes" "context" + "encoding/json" "fmt" + "io" + "net/http" + "os" "github.com/onkernel/cli/pkg/util" "github.com/onkernel/kernel-go-sdk" @@ -12,12 +17,12 @@ import ( ) // ProfilesService defines the subset of the Kernel SDK profile client that we use. -// Mirrors patterns from BrowsersService. type ProfilesService interface { Get(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *kernel.Profile, err error) List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.Profile, err error) Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) (err error) New(ctx context.Context, body kernel.ProfileNewParams, opts ...option.RequestOption) (res *kernel.Profile, err error) + Download(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *http.Response, err error) } type ProfilesGetInput struct { @@ -33,6 +38,12 @@ type ProfilesDeleteInput struct { SkipConfirm bool } +type ProfilesDownloadInput struct { + Identifier string + Output string + Pretty bool +} + // ProfilesCmd handles profile operations independent of cobra. type ProfilesCmd struct { profiles ProfilesService @@ -54,7 +65,13 @@ func (p ProfilesCmd) List(ctx context.Context) error { if name == "" { name = "-" } - rows = append(rows, []string{prof.ID, name, prof.CreatedAt.Format("2006-01-02 15:04:05"), prof.UpdatedAt.Format("2006-01-02 15:04:05"), prof.LastUsedAt.Format("2006-01-02 15:04:05")}) + rows = append(rows, []string{ + prof.ID, + name, + util.FormatLocal(prof.CreatedAt), + util.FormatLocal(prof.UpdatedAt), + util.FormatLocal(prof.LastUsedAt), + }) } printTableNoPad(rows, true) return nil @@ -76,8 +93,9 @@ func (p ProfilesCmd) Get(ctx context.Context, in ProfilesGetInput) error { rows := pterm.TableData{{"Property", "Value"}} rows = append(rows, []string{"ID", item.ID}) rows = append(rows, []string{"Name", name}) - rows = append(rows, []string{"Created At", item.CreatedAt.Format("2006-01-02 15:04:05")}) - rows = append(rows, []string{"Updated At", item.UpdatedAt.Format("2006-01-02 15:04:05")}) + rows = append(rows, []string{"Created At", util.FormatLocal(item.CreatedAt)}) + rows = append(rows, []string{"Updated At", util.FormatLocal(item.UpdatedAt)}) + rows = append(rows, []string{"Last Used At", util.FormatLocal(item.LastUsedAt)}) printTableNoPad(rows, true) return nil } @@ -98,8 +116,8 @@ func (p ProfilesCmd) Create(ctx context.Context, in ProfilesCreateInput) error { rows := pterm.TableData{{"Property", "Value"}} rows = append(rows, []string{"ID", item.ID}) rows = append(rows, []string{"Name", name}) - rows = append(rows, []string{"Created At", item.CreatedAt.Format("2006-01-02 15:04:05")}) - rows = append(rows, []string{"Updated At", item.UpdatedAt.Format("2006-01-02 15:04:05")}) + rows = append(rows, []string{"Created At", util.FormatLocal(item.CreatedAt)}) + rows = append(rows, []string{"Last Used At", util.FormatLocal(item.LastUsedAt)}) printTableNoPad(rows, true) return nil } @@ -142,6 +160,52 @@ func (p ProfilesCmd) Delete(ctx context.Context, in ProfilesDeleteInput) error { return nil } +func (p ProfilesCmd) Download(ctx context.Context, in ProfilesDownloadInput) error { + res, err := p.profiles.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 in.Pretty { + var buf bytes.Buffer + body, _ := io.ReadAll(res.Body) + if len(body) == 0 { + pterm.Error.Println("Empty response body") + return nil + } + if err := json.Indent(&buf, body, "", " "); err != nil { + pterm.Error.Printf("Failed to pretty-print JSON: %v\n", err) + return nil + } + if _, err := io.Copy(f, &buf); err != nil { + pterm.Error.Printf("Failed to write pretty-printed JSON: %v\n", err) + return nil + } + return nil + } else { + 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 profile to %s\n", in.Output) + return nil +} + // --- Cobra wiring --- var profilesCmd = &cobra.Command{ @@ -153,6 +217,7 @@ var profilesCmd = &cobra.Command{ var profilesListCmd = &cobra.Command{ Use: "list", Short: "List profiles", + Args: cobra.NoArgs, RunE: runProfilesList, } @@ -166,6 +231,7 @@ var profilesGetCmd = &cobra.Command{ var profilesCreateCmd = &cobra.Command{ Use: "create", Short: "Create a new profile", + Args: cobra.NoArgs, RunE: runProfilesCreate, } @@ -176,14 +242,24 @@ var profilesDeleteCmd = &cobra.Command{ RunE: runProfilesDelete, } +var profilesDownloadCmd = &cobra.Command{ + Use: "download ", + Short: "Download a profile as a ZIP archive", + Args: cobra.ExactArgs(1), + RunE: runProfilesDownload, +} + func init() { profilesCmd.AddCommand(profilesListCmd) profilesCmd.AddCommand(profilesGetCmd) profilesCmd.AddCommand(profilesCreateCmd) profilesCmd.AddCommand(profilesDeleteCmd) + profilesCmd.AddCommand(profilesDownloadCmd) profilesCreateCmd.Flags().String("name", "", "Optional unique profile name") profilesDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + profilesDownloadCmd.Flags().String("to", "", "Output zip file path") + profilesDownloadCmd.Flags().Bool("pretty", false, "Pretty-print JSON to file") } func runProfilesList(cmd *cobra.Command, args []string) error { @@ -215,3 +291,12 @@ func runProfilesDelete(cmd *cobra.Command, args []string) error { p := ProfilesCmd{profiles: &svc} return p.Delete(cmd.Context(), ProfilesDeleteInput{Identifier: args[0], SkipConfirm: skip}) } + +func runProfilesDownload(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + out, _ := cmd.Flags().GetString("to") + pretty, _ := cmd.Flags().GetBool("pretty") + svc := client.Profiles + p := ProfilesCmd{profiles: &svc} + return p.Download(cmd.Context(), ProfilesDownloadInput{Identifier: args[0], Output: out, Pretty: pretty}) +} diff --git a/go.mod b/go.mod index d27486e..434b050 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-20250828154810-51461c63b855 +replace github.com/onkernel/kernel-go-sdk => github.com/stainless-sdks/kernel-go v0.0.0-20250828180419-4c2c25792fa0 diff --git a/go.sum b/go.sum index f39769f..5c07859 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-20250828154810-51461c63b855 h1:X0+cCyCiU3RYx7E7sT6BD0QEFWro0IpapxBAkHJj18U= -github.com/stainless-sdks/kernel-go v0.0.0-20250828154810-51461c63b855/go.mod h1:q7wsAf+yjpY+w8jbAMciWCtCM0ZUxiw/5o2MSPTZS9E= +github.com/stainless-sdks/kernel-go v0.0.0-20250828180419-4c2c25792fa0 h1:AP4ED6aGstgV1rEX5iTpUyM4mYHi9ZY5/+RUPUupZaU= +github.com/stainless-sdks/kernel-go v0.0.0-20250828180419-4c2c25792fa0/go.mod h1:q7wsAf+yjpY+w8jbAMciWCtCM0ZUxiw/5o2MSPTZS9E= 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= diff --git a/pkg/util/timefmt.go b/pkg/util/timefmt.go new file mode 100644 index 0000000..bdeed38 --- /dev/null +++ b/pkg/util/timefmt.go @@ -0,0 +1,16 @@ +package util + +import "time" + +// DefaultTimeLayout is the standard layout used for displaying timestamps. +// Includes the local timezone abbreviation to make it clear times are local. +const DefaultTimeLayout = "2006-01-02 15:04:05 MST" + +// FormatLocal formats the provided time in the user's local timezone. +// If the time is zero, it returns "-". +func FormatLocal(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.In(time.Local).Format(DefaultTimeLayout) +} From a628a82e80ffeb992247eca8c47fe8e8080f291d Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 4 Sep 2025 11:21:52 -0400 Subject: [PATCH 3/5] kernel sdk 0.11 --- go.mod | 4 +--- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 434b050..50207da 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.10.1-0.20250827184402-40919678c68e + github.com/onkernel/kernel-go-sdk v0.11.0 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-20250828180419-4c2c25792fa0 diff --git a/go.sum b/go.sum index 5c07859..0e9577d 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.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/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-20250828180419-4c2c25792fa0 h1:AP4ED6aGstgV1rEX5iTpUyM4mYHi9ZY5/+RUPUupZaU= -github.com/stainless-sdks/kernel-go v0.0.0-20250828180419-4c2c25792fa0/go.mod h1:q7wsAf+yjpY+w8jbAMciWCtCM0ZUxiw/5o2MSPTZS9E= 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 c2a89a6e111c65962be843a3d05895178df6f26b Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 4 Sep 2025 12:45:10 -0400 Subject: [PATCH 4/5] use Get() + notfound check --- cmd/browsers.go | 20 ++++---------------- cmd/profiles.go | 36 +++++++++++++++++------------------- pkg/util/client.go | 13 +++++++++++++ 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index 49d27f9..49605d7 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -3,7 +3,6 @@ package cmd import ( "context" "encoding/base64" - "errors" "fmt" "io" "net/http" @@ -208,17 +207,6 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { - isNotFound := func(err error) bool { - if err == nil { - return false - } - var apierr *kernel.Error - if errors.As(err, &apierr) { - return apierr != nil && apierr.StatusCode == http.StatusNotFound - } - return false - } - if !in.SkipConfirm { browsers, err := b.browsers.List(ctx) if err != nil { @@ -258,7 +246,7 @@ func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { if found.Persistence.ID == in.Identifier { pterm.Info.Printf("Deleting browser with persistent ID: %s\n", in.Identifier) err = b.browsers.Delete(ctx, kernel.BrowserDeleteParams{PersistentID: in.Identifier}) - if err != nil && !isNotFound(err) { + if err != nil && !util.IsNotFound(err) { return util.CleanedUpSdkError{Err: err} } pterm.Success.Printf("Successfully deleted browser with persistent ID: %s\n", in.Identifier) @@ -267,7 +255,7 @@ func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { pterm.Info.Printf("Deleting browser with ID: %s\n", in.Identifier) err = b.browsers.DeleteByID(ctx, in.Identifier) - if err != nil && !isNotFound(err) { + if err != nil && !util.IsNotFound(err) { return util.CleanedUpSdkError{Err: err} } pterm.Success.Printf("Successfully deleted browser with ID: %s\n", in.Identifier) @@ -280,14 +268,14 @@ func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { // Attempt by session ID if err := b.browsers.DeleteByID(ctx, in.Identifier); err != nil { - if !isNotFound(err) { + if !util.IsNotFound(err) { nonNotFoundErrors = append(nonNotFoundErrors, err) } } // Attempt by persistent ID if err := b.browsers.Delete(ctx, kernel.BrowserDeleteParams{PersistentID: in.Identifier}); err != nil { - if !isNotFound(err) { + if !util.IsNotFound(err) { nonNotFoundErrors = append(nonNotFoundErrors, err) } } diff --git a/cmd/profiles.go b/cmd/profiles.go index 77a471e..3243c35 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -123,27 +123,21 @@ func (p ProfilesCmd) Create(ctx context.Context, in ProfilesCreateInput) error { } func (p ProfilesCmd) Delete(ctx context.Context, in ProfilesDeleteInput) error { - if !in.SkipConfirm { - // Try to resolve for a nicer message; avoid prompting for missing entries - list, err := p.profiles.List(ctx) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - var found *kernel.Profile - if list != nil { - for _, pr := range *list { - if pr.ID == in.Identifier || (pr.Name != "" && pr.Name == in.Identifier) { - cp := pr - found = &cp - break - } - } - } - if found == nil { - pterm.Error.Printf("Profile '%s' not found\n", in.Identifier) + // Resolve using Get first; treat not found as success with a message + item, err := p.profiles.Get(ctx, in.Identifier) + if err != nil { + if util.IsNotFound(err) { + pterm.Info.Printf("Profile '%s' not found\n", in.Identifier) return nil } - // Confirm + return util.CleanedUpSdkError{Err: err} + } + if item == nil || item.ID == "" { + pterm.Info.Printf("Profile '%s' not found\n", in.Identifier) + return nil + } + + if !in.SkipConfirm { msg := fmt.Sprintf("Are you sure you want to delete profile '%s'?", in.Identifier) pterm.DefaultInteractiveConfirm.DefaultText = msg ok, _ := pterm.DefaultInteractiveConfirm.Show() @@ -154,6 +148,10 @@ func (p ProfilesCmd) Delete(ctx context.Context, in ProfilesDeleteInput) error { } if err := p.profiles.Delete(ctx, in.Identifier); err != nil { + if util.IsNotFound(err) { + pterm.Info.Printf("Profile '%s' not found\n", in.Identifier) + return nil + } return util.CleanedUpSdkError{Err: err} } pterm.Success.Printf("Deleted profile: %s\n", in.Identifier) diff --git a/pkg/util/client.go b/pkg/util/client.go index 70403be..ceb022e 100644 --- a/pkg/util/client.go +++ b/pkg/util/client.go @@ -3,6 +3,7 @@ package util import ( "bytes" "encoding/json" + "errors" "io" "net/http" "os" @@ -65,3 +66,15 @@ func showUpgradeMessage() { pterm.Error.Println("Your Kernel CLI is out of date and is not compatible with this API.") pterm.Info.Println("Please upgrade by running: `brew upgrade onkernel/tap/kernel`") } + +// IsNotFound returns true if the error is a Kernel API error with HTTP 404. +func IsNotFound(err error) bool { + if err == nil { + return false + } + var apierr *kernel.Error + if errors.As(err, &apierr) { + return apierr != nil && apierr.StatusCode == http.StatusNotFound + } + return false +} From 7ef42eec53b2fe84ee068d136ec6bd1cf7154d41 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 4 Sep 2025 14:04:56 -0400 Subject: [PATCH 5/5] fix test --- cmd/profiles_test.go | 107 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 6 deletions(-) diff --git a/cmd/profiles_test.go b/cmd/profiles_test.go index 353f83e..3573197 100644 --- a/cmd/profiles_test.go +++ b/cmd/profiles_test.go @@ -4,7 +4,10 @@ import ( "bytes" "context" "errors" + "io" + "net/http" "os" + "strings" "testing" "time" @@ -40,10 +43,11 @@ func captureProfilesOutput(t *testing.T) *bytes.Buffer { // FakeProfilesService implements ProfilesService type FakeProfilesService struct { - GetFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.Profile, error) - ListFunc func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.Profile, error) - DeleteFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) error - NewFunc func(ctx context.Context, body kernel.ProfileNewParams, opts ...option.RequestOption) (*kernel.Profile, error) + GetFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.Profile, error) + ListFunc func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.Profile, error) + DeleteFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) error + NewFunc func(ctx context.Context, body kernel.ProfileNewParams, opts ...option.RequestOption) (*kernel.Profile, error) + DownloadFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) } func (f *FakeProfilesService) Get(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.Profile, error) { @@ -65,6 +69,12 @@ func (f *FakeProfilesService) Delete(ctx context.Context, idOrName string, opts } return nil } +func (f *FakeProfilesService) 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 *FakeProfilesService) New(ctx context.Context, body kernel.ProfileNewParams, opts ...option.RequestOption) (*kernel.Profile, error) { if f.NewFunc != nil { return f.NewFunc(ctx, body, opts...) @@ -141,8 +151,9 @@ func TestProfilesCreate_Error(t *testing.T) { func TestProfilesDelete_ConfirmNotFound(t *testing.T) { buf := captureProfilesOutput(t) - rows := []kernel.Profile{{ID: "a"}} - fake := &FakeProfilesService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.Profile, error) { return &rows, nil }} + fake := &FakeProfilesService{GetFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.Profile, error) { + return nil, &kernel.Error{StatusCode: http.StatusNotFound} + }} p := ProfilesCmd{profiles: fake} _ = p.Delete(context.Background(), ProfilesDeleteInput{Identifier: "missing"}) assert.Contains(t, buf.String(), "not found") @@ -155,3 +166,87 @@ func TestProfilesDelete_SkipConfirm(t *testing.T) { _ = p.Delete(context.Background(), ProfilesDeleteInput{Identifier: "a", SkipConfirm: true}) assert.Contains(t, buf.String(), "Deleted profile: a") } + +func TestProfilesDownload_MissingOutput(t *testing.T) { + buf := captureProfilesOutput(t) + fake := &FakeProfilesService{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 + }} + p := ProfilesCmd{profiles: fake} + _ = p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", Output: "", Pretty: false}) + assert.Contains(t, buf.String(), "Missing --to output file path") +} + +func TestProfilesDownload_RawSuccess(t *testing.T) { + buf := captureProfilesOutput(t) + f, err := os.CreateTemp("", "profile-*.zip") + assert.NoError(t, err) + name := f.Name() + _ = f.Close() + defer os.Remove(name) + + content := "hello" + fake := &FakeProfilesService{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 + }} + p := ProfilesCmd{profiles: fake} + _ = p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", Output: name, Pretty: false}) + + b, readErr := os.ReadFile(name) + assert.NoError(t, readErr) + assert.Equal(t, content, string(b)) + assert.Contains(t, buf.String(), "Saved profile to "+name) +} + +func TestProfilesDownload_PrettySuccess(t *testing.T) { + f, err := os.CreateTemp("", "profile-*.json") + assert.NoError(t, err) + name := f.Name() + _ = f.Close() + defer os.Remove(name) + + jsonBody := "{\"a\":1}" + fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(jsonBody)), Header: http.Header{}}, nil + }} + p := ProfilesCmd{profiles: fake} + _ = p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", Output: name, Pretty: true}) + + b, readErr := os.ReadFile(name) + assert.NoError(t, readErr) + out := string(b) + assert.Contains(t, out, "\n") + assert.Contains(t, out, "\"a\": 1") +} + +func TestProfilesDownload_PrettyEmptyBody(t *testing.T) { + buf := captureProfilesOutput(t) + f, err := os.CreateTemp("", "profile-*.json") + assert.NoError(t, err) + name := f.Name() + _ = f.Close() + defer os.Remove(name) + + fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{}}, nil + }} + p := ProfilesCmd{profiles: fake} + _ = p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", Output: name, Pretty: true}) + assert.Contains(t, buf.String(), "Empty response body") +} + +func TestProfilesDownload_PrettyInvalidJSON(t *testing.T) { + buf := captureProfilesOutput(t) + f, err := os.CreateTemp("", "profile-*.json") + assert.NoError(t, err) + name := f.Name() + _ = f.Close() + defer os.Remove(name) + + fake := &FakeProfilesService{DownloadFunc: func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("not json")), Header: http.Header{}}, nil + }} + p := ProfilesCmd{profiles: fake} + _ = p.Download(context.Background(), ProfilesDownloadInput{Identifier: "p1", Output: name, Pretty: true}) + assert.Contains(t, buf.String(), "Failed to pretty-print JSON") +}