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..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" @@ -76,10 +75,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 +117,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 +126,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 +163,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,23 +194,19 @@ 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 } 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 { @@ -225,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) @@ -234,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) @@ -247,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) } } @@ -337,7 +358,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 +407,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 +433,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 +618,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 +921,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 +949,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 +1318,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 +1343,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 new file mode 100644 index 0000000..3243c35 --- /dev/null +++ b/cmd/profiles.go @@ -0,0 +1,300 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "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. +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 { + Identifier string +} + +type ProfilesCreateInput struct { + Name string +} + +type ProfilesDeleteInput struct { + Identifier string + SkipConfirm bool +} + +type ProfilesDownloadInput struct { + Identifier string + Output string + Pretty 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, + util.FormatLocal(prof.CreatedAt), + util.FormatLocal(prof.UpdatedAt), + util.FormatLocal(prof.LastUsedAt), + }) + } + 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", 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 +} + +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", util.FormatLocal(item.CreatedAt)}) + rows = append(rows, []string{"Last Used At", util.FormatLocal(item.LastUsedAt)}) + printTableNoPad(rows, true) + return nil +} + +func (p ProfilesCmd) Delete(ctx context.Context, in ProfilesDeleteInput) error { + // 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 + } + 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() + if !ok { + pterm.Info.Println("Deletion cancelled") + return nil + } + } + + 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) + 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{ + Use: "profiles", + Short: "Manage profiles", + Long: "Commands for managing Kernel browser profiles", +} + +var profilesListCmd = &cobra.Command{ + Use: "list", + Short: "List profiles", + Args: cobra.NoArgs, + 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", + Args: cobra.NoArgs, + RunE: runProfilesCreate, +} + +var profilesDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a profile by ID or name", + Args: cobra.ExactArgs(1), + 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 { + 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}) +} + +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/cmd/profiles_test.go b/cmd/profiles_test.go new file mode 100644 index 0000000..3573197 --- /dev/null +++ b/cmd/profiles_test.go @@ -0,0 +1,252 @@ +package cmd + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "os" + "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" +) + +// 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) + 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) { + 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) 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...) + } + 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) + 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") +} + +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") +} + +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") +} 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..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 diff --git a/go.sum b/go.sum index a713339..0e9577d 100644 --- a/go.sum +++ b/go.sum @@ -91,8 +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.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/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= 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 +} 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) +}