diff --git a/README.md b/README.md index 2ecbe3c..5a17fdc 100644 --- a/README.md +++ b/README.md @@ -106,17 +106,20 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). ### App Management - `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 invoke ` - Run an app action + - `--version `, `-v` - Specify app version (default: latest) - `--payload `, `-p` - JSON payload for the action - `--sync`, `-s` - Invoke synchronously (timeout after 60s) - `kernel app list` - List deployed apps + - `--name ` - Filter by app name - `--version ` - Filter by version @@ -141,6 +144,86 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `-y, --yes` - Skip confirmation prompt - `kernel browsers view ` - Get live view URL for a browser +### Browser Logs + +- `kernel browsers logs stream ` - Stream browser logs + - `--source ` - Log source: "path" or "supervisor" (required) + - `--follow` - Follow the log stream (default: true) + - `--path ` - File path when source=path + - `--supervisor-process ` - Supervisor process name when source=supervisor. Most useful value is "chromium" + +### Browser Replays + +- `kernel browsers replays list ` - List replays for a browser +- `kernel browsers replays start ` - Start a replay recording + - `--framerate ` - Recording framerate (fps) + - `--max-duration ` - Maximum duration in seconds +- `kernel browsers replays stop ` - Stop a replay recording +- `kernel browsers replays download ` - Download a replay video + - `-o, --output ` - Output file path for the replay video + +### Browser Process Control + +- `kernel browsers process exec [--] [command...]` - Execute a command synchronously + - `--command ` - Command to execute (optional; if omitted, trailing args are executed via /bin/bash -c) + - `--args ` - Command arguments + - `--cwd ` - Working directory + - `--timeout ` - Timeout in seconds + - `--as-user ` - Run as user + - `--as-root` - Run as root +- `kernel browsers process spawn [--] [command...]` - Execute a command asynchronously + - `--command ` - Command to execute (optional; if omitted, trailing args are executed via /bin/bash -c) + - `--args ` - Command arguments + - `--cwd ` - Working directory + - `--timeout ` - Timeout in seconds + - `--as-user ` - Run as user + - `--as-root` - Run as root +- `kernel browsers process kill ` - Send a signal to a process + - `--signal ` - Signal to send: TERM, KILL, INT, HUP (default: TERM) +- `kernel browsers process status ` - Get process status +- `kernel browsers process stdin ` - Write to process stdin (base64) + - `--data-b64 ` - Base64-encoded data to write to stdin (required) +- `kernel browsers process stdout-stream ` - Stream process stdout/stderr + +### Browser Filesystem + +- `kernel browsers fs new-directory ` - Create a new directory + - `--path ` - Absolute directory path to create (required) + - `--mode ` - Directory mode (octal string) +- `kernel browsers fs delete-directory ` - Delete a directory + - `--path ` - Absolute directory path to delete (required) +- `kernel browsers fs delete-file ` - Delete a file + - `--path ` - Absolute file path to delete (required) +- `kernel browsers fs download-dir-zip ` - Download a directory as zip + - `--path ` - Absolute directory path to download (required) + - `-o, --output ` - Output zip file path +- `kernel browsers fs file-info ` - Get file or directory info + - `--path ` - Absolute file or directory path (required) +- `kernel browsers fs list-files ` - List files in a directory + - `--path ` - Absolute directory path (required) +- `kernel browsers fs move ` - Move or rename a file or directory + - `--src ` - Absolute source path (required) + - `--dest ` - Absolute destination path (required) +- `kernel browsers fs read-file ` - Read a file + - `--path ` - Absolute file path (required) + - `-o, --output ` - Output file path (optional) +- `kernel browsers fs set-permissions ` - Set file permissions or ownership + - `--path ` - Absolute path (required) + - `--mode ` - File mode bits (octal string) (required) + - `--owner ` - New owner username or UID + - `--group ` - New group name or GID +- `kernel browsers fs upload ` - Upload one or more files + - `--file ` - Mapping local:remote (repeatable) + - `--dest-dir ` - Destination directory for uploads + - `--paths ` - Local file paths to upload +- `kernel browsers fs upload-zip ` - Upload a zip and extract it + - `--zip ` - Local zip file path (required) + - `--dest-dir ` - Destination directory to extract to (required) +- `kernel browsers fs write-file ` - Write a file from local data + - `--path ` - Destination absolute file path (required) + - `--mode ` - File mode (octal string) + - `--source ` - Local source file path (required) + ## Examples ### Deploy with environment variables @@ -199,6 +282,21 @@ kernel browsers delete --by-persistent-id my-browser-session --yes # Get live view URL kernel browsers view --by-id browser123 + +# Stream browser logs +kernel browsers logs stream my-browser --source supervisor --follow --supervisor-process chromium + +# Start a replay recording +kernel browsers replays start my-browser --framerate 30 --max-duration 300 + +# Execute a command in the browser VM +kernel browsers process exec my-browser -- ls -alh /tmp + +# Upload files to the browser VM +kernel browsers fs upload my-browser --file "local.txt:remote.txt" --dest-dir "/tmp" + +# List files in a directory +kernel browsers fs list-files my-browser --path "/tmp" ``` ## Getting Help diff --git a/cmd/app.go b/cmd/app.go index 8ea33db..c7f484b 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -102,7 +102,7 @@ func runAppList(cmd *cobra.Command, args []string) error { }) } - pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + printTableNoPad(tableData, true) return nil } @@ -146,6 +146,6 @@ func runAppHistory(cmd *cobra.Command, args []string) error { }) } - pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + printTableNoPad(tableData, true) return nil } diff --git a/cmd/browsers.go b/cmd/browsers.go index 1d66bd0..f0cc82e 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -2,12 +2,20 @@ package cmd import ( "context" + "encoding/base64" "errors" "fmt" + "io" "net/http" + "os" + "path/filepath" + "strings" + "github.com/onkernel/cli/pkg/util" "github.com/onkernel/kernel-go-sdk" "github.com/onkernel/kernel-go-sdk/option" + "github.com/onkernel/kernel-go-sdk/packages/ssestream" + "github.com/onkernel/kernel-go-sdk/shared" "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -21,6 +29,45 @@ type BrowsersService interface { DeleteByID(ctx context.Context, id string, opts ...option.RequestOption) (err error) } +// BrowserReplaysService defines the subset we use for browser replays. +type BrowserReplaysService interface { + List(ctx context.Context, id string, opts ...option.RequestOption) (res *[]kernel.BrowserReplayListResponse, err error) + Download(ctx context.Context, replayID string, query kernel.BrowserReplayDownloadParams, opts ...option.RequestOption) (res *http.Response, err error) + Start(ctx context.Context, id string, body kernel.BrowserReplayStartParams, opts ...option.RequestOption) (res *kernel.BrowserReplayStartResponse, err error) + Stop(ctx context.Context, replayID string, body kernel.BrowserReplayStopParams, opts ...option.RequestOption) (err error) +} + +// BrowserFSService defines the subset we use for browser filesystem APIs. +type BrowserFSService interface { + NewDirectory(ctx context.Context, id string, body kernel.BrowserFNewDirectoryParams, opts ...option.RequestOption) (err error) + DeleteDirectory(ctx context.Context, id string, body kernel.BrowserFDeleteDirectoryParams, opts ...option.RequestOption) (err error) + DeleteFile(ctx context.Context, id string, body kernel.BrowserFDeleteFileParams, opts ...option.RequestOption) (err error) + DownloadDirZip(ctx context.Context, id string, query kernel.BrowserFDownloadDirZipParams, opts ...option.RequestOption) (res *http.Response, err error) + FileInfo(ctx context.Context, id string, query kernel.BrowserFFileInfoParams, opts ...option.RequestOption) (res *kernel.BrowserFFileInfoResponse, err error) + ListFiles(ctx context.Context, id string, query kernel.BrowserFListFilesParams, opts ...option.RequestOption) (res *[]kernel.BrowserFListFilesResponse, err error) + Move(ctx context.Context, id string, body kernel.BrowserFMoveParams, opts ...option.RequestOption) (err error) + ReadFile(ctx context.Context, id string, query kernel.BrowserFReadFileParams, opts ...option.RequestOption) (res *http.Response, err error) + SetFilePermissions(ctx context.Context, id string, body kernel.BrowserFSetFilePermissionsParams, opts ...option.RequestOption) (err error) + Upload(ctx context.Context, id string, body kernel.BrowserFUploadParams, opts ...option.RequestOption) (err error) + UploadZip(ctx context.Context, id string, body kernel.BrowserFUploadZipParams, opts ...option.RequestOption) (err error) + WriteFile(ctx context.Context, id string, contents io.Reader, body kernel.BrowserFWriteFileParams, opts ...option.RequestOption) (err error) +} + +// BrowserProcessService defines the subset we use for browser process APIs. +type BrowserProcessService interface { + Exec(ctx context.Context, id string, body kernel.BrowserProcessExecParams, opts ...option.RequestOption) (res *kernel.BrowserProcessExecResponse, err error) + Kill(ctx context.Context, processID string, params kernel.BrowserProcessKillParams, opts ...option.RequestOption) (res *kernel.BrowserProcessKillResponse, err error) + Spawn(ctx context.Context, id string, body kernel.BrowserProcessSpawnParams, opts ...option.RequestOption) (res *kernel.BrowserProcessSpawnResponse, err error) + Status(ctx context.Context, processID string, query kernel.BrowserProcessStatusParams, opts ...option.RequestOption) (res *kernel.BrowserProcessStatusResponse, err error) + Stdin(ctx context.Context, processID string, params kernel.BrowserProcessStdinParams, opts ...option.RequestOption) (res *kernel.BrowserProcessStdinResponse, err error) + StdoutStreamStreaming(ctx context.Context, processID string, query kernel.BrowserProcessStdoutStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.BrowserProcessStdoutStreamResponse]) +} + +// BrowserLogService defines the subset we use for browser log APIs. +type BrowserLogService interface { + StreamStreaming(ctx context.Context, id string, query kernel.BrowserLogStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[shared.LogEvent]) +} + // BoolFlag captures whether a boolean flag was set explicitly and its value. type BoolFlag struct { Set bool @@ -47,6 +94,10 @@ type BrowsersViewInput struct { // BrowsersCmd is a cobra-independent command handler for browsers operations. type BrowsersCmd struct { browsers BrowsersService + replays BrowserReplaysService + fs BrowserFSService + process BrowserProcessService + logs BrowserLogService } func (b BrowsersCmd) List(ctx context.Context) error { @@ -54,8 +105,7 @@ func (b BrowsersCmd) List(ctx context.Context) error { browsers, err := b.browsers.List(ctx) if err != nil { - pterm.Error.Printf("Failed to list browsers: %v\n", err) - return nil + return util.CleanedUpSdkError{Err: err} } if browsers == nil || len(*browsers) == 0 { @@ -83,15 +133,13 @@ func (b BrowsersCmd) List(ctx context.Context) error { }) } - pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + printTableNoPad(tableData, true) return nil } func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { pterm.Info.Println("Creating browser session...") - params := kernel.BrowserNewParams{} - if in.PersistenceID != "" { params.Persistence = kernel.BrowserPersistenceParam{ID: in.PersistenceID} } @@ -107,8 +155,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { browser, err := b.browsers.New(ctx, params) if err != nil { - pterm.Error.Printf("Failed to create browser: %v\n", err) - return nil + return util.CleanedUpSdkError{Err: err} } tableData := pterm.TableData{ @@ -123,7 +170,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { tableData = append(tableData, []string{"Persistent ID", browser.Persistence.ID}) } - pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + printTableNoPad(tableData, true) return nil } @@ -142,8 +189,7 @@ func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { if !in.SkipConfirm { browsers, err := b.browsers.List(ctx) if err != nil { - pterm.Error.Printf("Failed to list browsers: %v\n", err) - return nil + return util.CleanedUpSdkError{Err: err} } if browsers == nil || len(*browsers) == 0 { pterm.Error.Println("No browsers found") @@ -180,8 +226,7 @@ func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { 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) { - pterm.Error.Printf("Failed to delete browser: %v\n", err) - return nil + return util.CleanedUpSdkError{Err: err} } pterm.Success.Printf("Successfully deleted browser with persistent ID: %s\n", in.Identifier) return nil @@ -190,8 +235,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) { - pterm.Error.Printf("Failed to delete browser: %v\n", err) - return nil + return util.CleanedUpSdkError{Err: err} } pterm.Success.Printf("Successfully deleted browser with ID: %s\n", in.Identifier) return nil @@ -217,8 +261,7 @@ func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { if len(nonNotFoundErrors) >= 2 { // Both failed with meaningful errors; report one - pterm.Error.Printf("Failed to delete browser: %v\n", nonNotFoundErrors[0]) - return nil + return util.CleanedUpSdkError{Err: nonNotFoundErrors[0]} } pterm.Success.Printf("Successfully deleted (or already absent) browser: %s\n", in.Identifier) @@ -228,8 +271,7 @@ func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { func (b BrowsersCmd) View(ctx context.Context, in BrowsersViewInput) error { browsers, err := b.browsers.List(ctx) if err != nil { - pterm.Error.Printf("Failed to list browsers: %v\n", err) - return nil + return util.CleanedUpSdkError{Err: err} } if browsers == nil || len(*browsers) == 0 { @@ -255,110 +297,1324 @@ func (b BrowsersCmd) View(ctx context.Context, in BrowsersViewInput) error { return nil } -var browsersCmd = &cobra.Command{ - Use: "browsers", - Short: "Manage browsers", - Long: "Commands for managing Kernel browsers", +// Logs +type BrowsersLogsStreamInput struct { + Identifier string + Source string + Follow BoolFlag + Path string + SupervisorProcess string } -var browsersListCmd = &cobra.Command{ - Use: "list", - Short: "List running or persistent browsers", - RunE: runBrowsersList, +func (b BrowsersCmd) LogsStream(ctx context.Context, in BrowsersLogsStreamInput) error { + if b.logs == nil { + pterm.Error.Println("logs 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 + } + params := kernel.BrowserLogStreamParams{Source: kernel.BrowserLogStreamParamsSource(in.Source)} + if in.Follow.Set { + params.Follow = kernel.Opt(in.Follow.Value) + } + if in.Path != "" { + params.Path = kernel.Opt(in.Path) + } + if in.SupervisorProcess != "" { + params.SupervisorProcess = kernel.Opt(in.SupervisorProcess) + } + stream := b.logs.StreamStreaming(ctx, br.SessionID, params) + if stream == nil { + pterm.Error.Println("failed to open log stream") + return nil + } + 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)) + } + if err := stream.Err(); err != nil { + return util.CleanedUpSdkError{Err: err} + } + return nil } -var browsersCreateCmd = &cobra.Command{ - Use: "create", - Short: "Create a new browser session", - RunE: runBrowsersCreate, +// Replays +type BrowsersReplaysListInput struct { + Identifier string } -var browsersDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a browser", - Args: cobra.ExactArgs(1), - RunE: runBrowsersDelete, +type BrowsersReplaysStartInput struct { + Identifier string + Framerate int + MaxDurationSeconds int } -var browsersViewCmd = &cobra.Command{ - Use: "view ", - Short: "Get the live view URL for a browser", - Args: cobra.ExactArgs(1), - RunE: runBrowsersView, +type BrowsersReplaysStopInput struct { + Identifier string + ReplayID string } -func init() { - browsersCmd.AddCommand(browsersListCmd) - browsersCmd.AddCommand(browsersCreateCmd) - browsersCmd.AddCommand(browsersDeleteCmd) - browsersCmd.AddCommand(browsersViewCmd) +type BrowsersReplaysDownloadInput struct { + Identifier string + ReplayID string + Output string +} - // 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") - browsersCreateCmd.Flags().BoolP("headless", "H", false, "Launch browser without GUI access") - browsersCreateCmd.Flags().IntP("timeout", "t", 60, "Timeout in seconds for the browser session") +func (b BrowsersCmd) ReplaysList(ctx context.Context, in BrowsersReplaysListInput) error { + 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 + } + items, err := b.replays.List(ctx, br.SessionID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if items == nil || len(*items) == 0 { + pterm.Info.Println("No replays found") + return nil + } + 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)}) + } + printTableNoPad(rows, true) + return nil +} - // Add flags for delete command - browsersDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") +func (b BrowsersCmd) ReplaysStart(ctx context.Context, in BrowsersReplaysStartInput) error { + 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 + } + body := kernel.BrowserReplayStartParams{} + if in.Framerate > 0 { + body.Framerate = kernel.Opt(int64(in.Framerate)) + } + if in.MaxDurationSeconds > 0 { + body.MaxDurationInSeconds = kernel.Opt(int64(in.MaxDurationSeconds)) + } + res, err := b.replays.Start(ctx, br.SessionID, body) + 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")}} + printTableNoPad(rows, true) + return nil +} - // no flags for view; it takes a single positional argument +func (b BrowsersCmd) ReplaysStop(ctx context.Context, in BrowsersReplaysStopInput) error { + 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 + } + err = b.replays.Stop(ctx, in.ReplayID, kernel.BrowserReplayStopParams{ID: br.SessionID}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Stopped replay %s for browser %s\n", in.ReplayID, br.SessionID) + return nil } -func runBrowsersList(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - svc := client.Browsers - b := BrowsersCmd{browsers: &svc} - return b.List(cmd.Context()) +func (b BrowsersCmd) ReplaysDownload(ctx context.Context, in BrowsersReplaysDownloadInput) error { + 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 + } + res, err := b.replays.Download(ctx, in.ReplayID, kernel.BrowserReplayDownloadParams{ID: br.SessionID}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + defer res.Body.Close() + if in.Output == "" { + pterm.Info.Printf("Downloaded replay %s (%s)\n", in.ReplayID, res.Header.Get("content-type")) + _, _ = 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 replay to %s\n", in.Output) + return nil } -func runBrowsersCreate(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) +// Process +type BrowsersProcessExecInput struct { + Identifier string + Command string + Args []string + Cwd string + Timeout int + AsUser string + AsRoot BoolFlag +} - // Get flag values - persistenceID, _ := cmd.Flags().GetString("persistent-id") - stealthVal, _ := cmd.Flags().GetBool("stealth") - headlessVal, _ := cmd.Flags().GetBool("headless") - timeout, _ := cmd.Flags().GetInt("timeout") +type BrowsersProcessSpawnInput = BrowsersProcessExecInput - in := BrowsersCreateInput{ - PersistenceID: persistenceID, - TimeoutSeconds: timeout, - Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealthVal}, - Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, - } +type BrowsersProcessKillInput struct { + Identifier string + ProcessID string + Signal string +} - svc := client.Browsers - b := BrowsersCmd{browsers: &svc} - return b.Create(cmd.Context(), in) +type BrowsersProcessStatusInput struct { + Identifier string + ProcessID string } -func runBrowsersDelete(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) +type BrowsersProcessStdinInput struct { + Identifier string + ProcessID string + DataB64 string +} - identifier := args[0] - skipConfirm, _ := cmd.Flags().GetBool("yes") +type BrowsersProcessStdoutStreamInput struct { + Identifier string + ProcessID string +} - in := BrowsersDeleteInput{Identifier: identifier, SkipConfirm: skipConfirm} - svc := client.Browsers - b := BrowsersCmd{browsers: &svc} - return b.Delete(cmd.Context(), in) +func (b BrowsersCmd) ProcessExec(ctx context.Context, in BrowsersProcessExecInput) error { + if b.process == nil { + pterm.Error.Println("process 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 + } + params := kernel.BrowserProcessExecParams{Command: in.Command} + if len(in.Args) > 0 { + params.Args = in.Args + } + if in.Cwd != "" { + params.Cwd = kernel.Opt(in.Cwd) + } + if in.Timeout > 0 { + params.TimeoutSec = kernel.Opt(int64(in.Timeout)) + } + if in.AsUser != "" { + params.AsUser = kernel.Opt(in.AsUser) + } + if in.AsRoot.Set { + params.AsRoot = kernel.Opt(in.AsRoot.Value) + } + res, err := b.process.Exec(ctx, br.SessionID, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + rows := pterm.TableData{{"Property", "Value"}, {"Exit Code", fmt.Sprintf("%d", res.ExitCode)}, {"Duration (ms)", fmt.Sprintf("%d", res.DurationMs)}} + printTableNoPad(rows, true) + if res.StdoutB64 != "" { + data, err := base64.StdEncoding.DecodeString(res.StdoutB64) + if err != nil { + pterm.Error.Printf("stdout decode error: %v\n", err) + } else if len(data) > 0 { + pterm.Info.Println("stdout:") + os.Stdout.Write(data) + if data[len(data)-1] != '\n' { + fmt.Println() + } + } + } + if res.StderrB64 != "" { + data, err := base64.StdEncoding.DecodeString(res.StderrB64) + if err != nil { + pterm.Error.Printf("stderr decode error: %v\n", err) + } else if len(data) > 0 { + pterm.Info.Println("stderr:") + os.Stderr.Write(data) + if data[len(data)-1] != '\n' { + fmt.Fprintln(os.Stderr) + } + } + } + return nil } -func runBrowsersView(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) +func (b BrowsersCmd) ProcessSpawn(ctx context.Context, in BrowsersProcessSpawnInput) error { + if b.process == nil { + pterm.Error.Println("process 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 + } + params := kernel.BrowserProcessSpawnParams{Command: in.Command} + if len(in.Args) > 0 { + params.Args = in.Args + } + if in.Cwd != "" { + params.Cwd = kernel.Opt(in.Cwd) + } + if in.Timeout > 0 { + params.TimeoutSec = kernel.Opt(int64(in.Timeout)) + } + if in.AsUser != "" { + params.AsUser = kernel.Opt(in.AsUser) + } + if in.AsRoot.Set { + params.AsRoot = kernel.Opt(in.AsRoot.Value) + } + res, err := b.process.Spawn(ctx, br.SessionID, params) + 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")}} + printTableNoPad(rows, true) + return nil +} - identifier := args[0] +func (b BrowsersCmd) ProcessKill(ctx context.Context, in BrowsersProcessKillInput) error { + if b.process == nil { + pterm.Error.Println("process 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 + } + params := kernel.BrowserProcessKillParams{ID: br.SessionID, Signal: kernel.BrowserProcessKillParamsSignal(in.Signal)} + _, err = b.process.Kill(ctx, in.ProcessID, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Sent %s to process %s on %s\n", in.Signal, in.ProcessID, br.SessionID) + return nil +} - in := BrowsersViewInput{Identifier: identifier} - svc := client.Browsers - b := BrowsersCmd{browsers: &svc} - return b.View(cmd.Context(), in) +func (b BrowsersCmd) ProcessStatus(ctx context.Context, in BrowsersProcessStatusInput) error { + if b.process == nil { + pterm.Error.Println("process 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 + } + res, err := b.process.Status(ctx, in.ProcessID, kernel.BrowserProcessStatusParams{ID: br.SessionID}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + rows := pterm.TableData{{"Property", "Value"}, {"State", string(res.State)}, {"CPU %", fmt.Sprintf("%.2f", res.CPUPct)}, {"Mem Bytes", fmt.Sprintf("%d", res.MemBytes)}, {"Exit Code", fmt.Sprintf("%d", res.ExitCode)}} + printTableNoPad(rows, true) + return nil } -func truncateURL(url string, maxLen int) string { - if len(url) <= maxLen { - return url +func (b BrowsersCmd) ProcessStdin(ctx context.Context, in BrowsersProcessStdinInput) error { + if b.process == nil { + pterm.Error.Println("process service not available") + return nil } - return url[:maxLen-3] + "..." + 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 + } + _, err = b.process.Stdin(ctx, in.ProcessID, kernel.BrowserProcessStdinParams{ID: br.SessionID, DataB64: in.DataB64}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Println("Wrote to stdin") + return nil +} + +func (b BrowsersCmd) ProcessStdoutStream(ctx context.Context, in BrowsersProcessStdoutStreamInput) error { + if b.process == nil { + pterm.Error.Println("process 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 + } + stream := b.process.StdoutStreamStreaming(ctx, in.ProcessID, kernel.BrowserProcessStdoutStreamParams{ID: br.SessionID}) + if stream == nil { + pterm.Error.Println("failed to open stdout stream") + return nil + } + defer stream.Close() + for stream.Next() { + ev := stream.Current() + if ev.Event == "exit" { + pterm.Info.Printf("process exited with code %d\n", ev.ExitCode) + continue + } + data, err := base64.StdEncoding.DecodeString(ev.DataB64) + if err != nil { + pterm.Error.Printf("decode error: %v\n", err) + continue + } + os.Stdout.Write(data) + } + if err := stream.Err(); err != nil { + return util.CleanedUpSdkError{Err: err} + } + return nil +} + +// FS (minimal scaffolding) +type BrowsersFSNewDirInput struct { + Identifier string + Path string + Mode string +} + +type BrowsersFSDeleteDirInput struct { + Identifier string + Path string +} + +type BrowsersFSDeleteFileInput struct { + Identifier string + Path string +} + +type BrowsersFSDownloadDirZipInput struct { + Identifier string + Path string + Output string +} + +type BrowsersFSFileInfoInput struct { + Identifier string + Path string +} + +type BrowsersFSListFilesInput struct { + Identifier string + Path string +} + +type BrowsersFSMoveInput struct { + Identifier string + SrcPath string + DestPath string +} + +type BrowsersFSReadFileInput struct { + Identifier string + Path string + Output string +} + +type BrowsersFSSetPermsInput struct { + Identifier string + Path string + Mode string + Owner string + Group string +} + +// Upload inputs +type BrowsersFSUploadInput struct { + Identifier string + Mappings []struct { + Local string + Dest string + } + DestDir string + Paths []string +} + +type BrowsersFSUploadZipInput struct { + Identifier string + ZipPath string + DestDir string +} + +type BrowsersFSWriteFileInput struct { + Identifier string + DestPath string + Mode string + SourcePath string +} + +func (b BrowsersCmd) FSNewDirectory(ctx context.Context, in BrowsersFSNewDirInput) error { + if b.fs == nil { + pterm.Error.Println("fs 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 + } + params := kernel.BrowserFNewDirectoryParams{Path: in.Path} + if in.Mode != "" { + params.Mode = kernel.Opt(in.Mode) + } + if err := b.fs.NewDirectory(ctx, br.SessionID, params); err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Created directory %s\n", in.Path) + return nil +} + +func (b BrowsersCmd) FSDeleteDirectory(ctx context.Context, in BrowsersFSDeleteDirInput) error { + if b.fs == nil { + pterm.Error.Println("fs 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 err := b.fs.DeleteDirectory(ctx, br.SessionID, kernel.BrowserFDeleteDirectoryParams{Path: in.Path}); err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Deleted directory %s\n", in.Path) + return nil +} + +func (b BrowsersCmd) FSDeleteFile(ctx context.Context, in BrowsersFSDeleteFileInput) error { + if b.fs == nil { + pterm.Error.Println("fs 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 err := b.fs.DeleteFile(ctx, br.SessionID, kernel.BrowserFDeleteFileParams{Path: in.Path}); err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Deleted file %s\n", in.Path) + return nil +} + +func (b BrowsersCmd) FSDownloadDirZip(ctx context.Context, in BrowsersFSDownloadDirZipInput) error { + if b.fs == nil { + pterm.Error.Println("fs 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 + } + res, err := b.fs.DownloadDirZip(ctx, br.SessionID, kernel.BrowserFDownloadDirZipParams{Path: in.Path}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + defer res.Body.Close() + if in.Output == "" { + _, _ = io.Copy(io.Discard, res.Body) + pterm.Info.Println("Downloaded zip (discarded; specify --output to save)") + 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 zip to %s\n", in.Output) + return nil +} + +func (b BrowsersCmd) FSFileInfo(ctx context.Context, in BrowsersFSFileInfoInput) error { + if b.fs == nil { + pterm.Error.Println("fs 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 + } + res, err := b.fs.FileInfo(ctx, br.SessionID, kernel.BrowserFFileInfoParams{Path: in.Path}) + 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")}} + printTableNoPad(rows, true) + return nil +} + +func (b BrowsersCmd) FSListFiles(ctx context.Context, in BrowsersFSListFilesInput) error { + if b.fs == nil { + pterm.Error.Println("fs 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 + } + res, err := b.fs.ListFiles(ctx, br.SessionID, kernel.BrowserFListFilesParams{Path: in.Path}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if res == nil || len(*res) == 0 { + pterm.Info.Println("No files found") + return nil + } + 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}) + } + printTableNoPad(rows, true) + return nil +} + +func (b BrowsersCmd) FSMove(ctx context.Context, in BrowsersFSMoveInput) error { + if b.fs == nil { + pterm.Error.Println("fs 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 err := b.fs.Move(ctx, br.SessionID, kernel.BrowserFMoveParams{SrcPath: in.SrcPath, DestPath: in.DestPath}); err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Moved %s -> %s\n", in.SrcPath, in.DestPath) + return nil +} + +func (b BrowsersCmd) FSReadFile(ctx context.Context, in BrowsersFSReadFileInput) error { + if b.fs == nil { + pterm.Error.Println("fs 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 + } + res, err := b.fs.ReadFile(ctx, br.SessionID, kernel.BrowserFReadFileParams{Path: in.Path}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + defer res.Body.Close() + if in.Output == "" { + _, _ = io.Copy(os.Stdout, 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 file to %s\n", in.Output) + return nil +} + +func (b BrowsersCmd) FSSetPermissions(ctx context.Context, in BrowsersFSSetPermsInput) error { + if b.fs == nil { + pterm.Error.Println("fs 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 + } + params := kernel.BrowserFSetFilePermissionsParams{Path: in.Path, Mode: in.Mode} + if in.Owner != "" { + params.Owner = kernel.Opt(in.Owner) + } + if in.Group != "" { + params.Group = kernel.Opt(in.Group) + } + if err := b.fs.SetFilePermissions(ctx, br.SessionID, params); err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Updated permissions for %s\n", in.Path) + return nil +} + +func (b BrowsersCmd) FSUpload(ctx context.Context, in BrowsersFSUploadInput) error { + if b.fs == nil { + pterm.Error.Println("fs 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 + } + var files []kernel.BrowserFUploadParamsFile + var toClose []io.Closer + for _, m := range in.Mappings { + f, err := os.Open(m.Local) + if err != nil { + pterm.Error.Printf("Failed to open %s: %v\n", m.Local, err) + for _, c := range toClose { + _ = c.Close() + } + return nil + } + toClose = append(toClose, f) + files = append(files, kernel.BrowserFUploadParamsFile{DestPath: m.Dest, File: f}) + } + if in.DestDir != "" && len(in.Paths) > 0 { + for _, lp := range in.Paths { + f, err := os.Open(lp) + if err != nil { + pterm.Error.Printf("Failed to open %s: %v\n", lp, err) + for _, c := range toClose { + _ = c.Close() + } + return nil + } + toClose = append(toClose, f) + dest := filepath.Join(in.DestDir, filepath.Base(lp)) + files = append(files, kernel.BrowserFUploadParamsFile{DestPath: dest, File: f}) + } + } + if len(files) == 0 { + pterm.Error.Println("no files specified for upload") + return nil + } + defer func() { + for _, c := range toClose { + _ = c.Close() + } + }() + if err := b.fs.Upload(ctx, br.SessionID, kernel.BrowserFUploadParams{Files: files}); err != nil { + return util.CleanedUpSdkError{Err: err} + } + if len(files) == 1 { + pterm.Success.Println("Uploaded 1 file") + } else { + pterm.Success.Printf("Uploaded %d files\n", len(files)) + } + return nil +} + +func (b BrowsersCmd) FSUploadZip(ctx context.Context, in BrowsersFSUploadZipInput) error { + if b.fs == nil { + pterm.Error.Println("fs 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 + } + f, err := os.Open(in.ZipPath) + if err != nil { + pterm.Error.Printf("Failed to open zip: %v\n", err) + return nil + } + defer f.Close() + if err := b.fs.UploadZip(ctx, br.SessionID, kernel.BrowserFUploadZipParams{DestPath: in.DestDir, ZipFile: f}); err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Uploaded zip to %s\n", in.DestDir) + return nil +} + +func (b BrowsersCmd) FSWriteFile(ctx context.Context, in BrowsersFSWriteFileInput) error { + if b.fs == nil { + pterm.Error.Println("fs 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 + } + var reader io.Reader + if in.SourcePath != "" { + f, err := os.Open(in.SourcePath) + if err != nil { + pterm.Error.Printf("Failed to open input: %v\n", err) + return nil + } + defer f.Close() + reader = f + } else { + pterm.Error.Println("--source is required") + return nil + } + params := kernel.BrowserFWriteFileParams{Path: in.DestPath} + if in.Mode != "" { + params.Mode = kernel.Opt(in.Mode) + } + if err := b.fs.WriteFile(ctx, br.SessionID, reader, params); err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Wrote file to %s\n", in.DestPath) + return nil +} + +var browsersCmd = &cobra.Command{ + Use: "browsers", + Short: "Manage browsers", + Long: "Commands for managing Kernel browsers", +} + +var browsersListCmd = &cobra.Command{ + Use: "list", + Short: "List running or persistent browsers", + RunE: runBrowsersList, +} + +var browsersCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new browser session", + RunE: runBrowsersCreate, +} + +var browsersDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a browser", + Args: cobra.ExactArgs(1), + RunE: runBrowsersDelete, +} + +var browsersViewCmd = &cobra.Command{ + Use: "view ", + Short: "Get the live view URL for a browser", + Args: cobra.ExactArgs(1), + RunE: runBrowsersView, +} + +func init() { + browsersCmd.AddCommand(browsersListCmd) + browsersCmd.AddCommand(browsersCreateCmd) + browsersCmd.AddCommand(browsersDeleteCmd) + browsersCmd.AddCommand(browsersViewCmd) + + // logs + logsRoot := &cobra.Command{Use: "logs", Short: "Browser logs operations"} + logsStream := &cobra.Command{Use: "stream ", Short: "Stream browser logs", Args: cobra.ExactArgs(1), RunE: runBrowsersLogsStream} + logsStream.Flags().String("source", "", "Log source: path or supervisor") + logsStream.Flags().Bool("follow", true, "Follow the log stream") + logsStream.Flags().String("path", "", "File path when source=path") + logsStream.Flags().String("supervisor-process", "", "Supervisor process name when source=supervisor. Useful values to use: chromium, kernel-images-api, neko") + _ = logsStream.MarkFlagRequired("source") + logsRoot.AddCommand(logsStream) + browsersCmd.AddCommand(logsRoot) + + // replays + replaysRoot := &cobra.Command{Use: "replays", Short: "Manage browser replays"} + replaysList := &cobra.Command{Use: "list ", Short: "List replays for a browser", Args: cobra.ExactArgs(1), RunE: runBrowsersReplaysList} + replaysStart := &cobra.Command{Use: "start ", Short: "Start a replay recording", Args: cobra.ExactArgs(1), RunE: runBrowsersReplaysStart} + replaysStart.Flags().Int("framerate", 0, "Recording framerate (fps)") + replaysStart.Flags().Int("max-duration", 0, "Maximum duration in seconds") + replaysStop := &cobra.Command{Use: "stop ", Short: "Stop a replay recording", Args: cobra.ExactArgs(2), RunE: runBrowsersReplaysStop} + replaysDownload := &cobra.Command{Use: "download ", Short: "Download a replay video", Args: cobra.ExactArgs(2), RunE: runBrowsersReplaysDownload} + replaysDownload.Flags().StringP("output", "o", "", "Output file path for the replay video") + replaysRoot.AddCommand(replaysList, replaysStart, replaysStop, replaysDownload) + browsersCmd.AddCommand(replaysRoot) + + // process + procRoot := &cobra.Command{Use: "process", Short: "Manage processes inside the browser VM"} + procExec := &cobra.Command{Use: "exec [--] [command...]", Short: "Execute a command synchronously", Args: cobra.MinimumNArgs(1), RunE: runBrowsersProcessExec} + procExec.Flags().String("command", "", "Command to execute (optional; if omitted, trailing args are executed via /bin/bash -c)") + procExec.Flags().StringSlice("args", []string{}, "Command arguments") + procExec.Flags().String("cwd", "", "Working directory") + procExec.Flags().Int("timeout", 0, "Timeout in seconds") + procExec.Flags().String("as-user", "", "Run as user") + procExec.Flags().Bool("as-root", false, "Run as root") + procSpawn := &cobra.Command{Use: "spawn [--] [command...]", Short: "Execute a command asynchronously", Args: cobra.MinimumNArgs(1), RunE: runBrowsersProcessSpawn} + procSpawn.Flags().String("command", "", "Command to execute (optional; if omitted, trailing args are executed via /bin/bash -c)") + procSpawn.Flags().StringSlice("args", []string{}, "Command arguments") + procSpawn.Flags().String("cwd", "", "Working directory") + procSpawn.Flags().Int("timeout", 0, "Timeout in seconds") + procSpawn.Flags().String("as-user", "", "Run as user") + procSpawn.Flags().Bool("as-root", false, "Run as root") + procKill := &cobra.Command{Use: "kill ", Short: "Send a signal to a process", Args: cobra.ExactArgs(2), RunE: runBrowsersProcessKill} + procKill.Flags().String("signal", "TERM", "Signal to send (TERM, KILL, INT, HUP)") + procStatus := &cobra.Command{Use: "status ", Short: "Get process status", Args: cobra.ExactArgs(2), RunE: runBrowsersProcessStatus} + procStdin := &cobra.Command{Use: "stdin ", Short: "Write to process stdin (base64)", Args: cobra.ExactArgs(2), RunE: runBrowsersProcessStdin} + procStdin.Flags().String("data-b64", "", "Base64-encoded data to write to stdin") + _ = procStdin.MarkFlagRequired("data-b64") + procStdoutStream := &cobra.Command{Use: "stdout-stream ", Short: "Stream process stdout/stderr", Args: cobra.ExactArgs(2), RunE: runBrowsersProcessStdoutStream} + procRoot.AddCommand(procExec, procSpawn, procKill, procStatus, procStdin, procStdoutStream) + browsersCmd.AddCommand(procRoot) + + // fs + fsRoot := &cobra.Command{Use: "fs", Short: "Browser filesystem operations"} + fsNewDir := &cobra.Command{Use: "new-directory ", Short: "Create a new directory", Args: cobra.ExactArgs(1), RunE: runBrowsersFSNewDirectory} + fsNewDir.Flags().String("path", "", "Absolute directory path to create") + _ = fsNewDir.MarkFlagRequired("path") + fsNewDir.Flags().String("mode", "", "Directory mode (octal string)") + fsDelDir := &cobra.Command{Use: "delete-directory ", Short: "Delete a directory", Args: cobra.ExactArgs(1), RunE: runBrowsersFSDeleteDirectory} + fsDelDir.Flags().String("path", "", "Absolute directory path to delete") + _ = fsDelDir.MarkFlagRequired("path") + fsDelFile := &cobra.Command{Use: "delete-file ", Short: "Delete a file", Args: cobra.ExactArgs(1), RunE: runBrowsersFSDeleteFile} + fsDelFile.Flags().String("path", "", "Absolute file path to delete") + _ = fsDelFile.MarkFlagRequired("path") + fsDownloadZip := &cobra.Command{Use: "download-dir-zip ", Short: "Download a directory as zip", Args: cobra.ExactArgs(1), RunE: runBrowsersFSDownloadDirZip} + fsDownloadZip.Flags().String("path", "", "Absolute directory path to download") + _ = fsDownloadZip.MarkFlagRequired("path") + fsDownloadZip.Flags().StringP("output", "o", "", "Output zip file path") + fsFileInfo := &cobra.Command{Use: "file-info ", Short: "Get file or directory info", Args: cobra.ExactArgs(1), RunE: runBrowsersFSFileInfo} + fsFileInfo.Flags().String("path", "", "Absolute file or directory path") + _ = fsFileInfo.MarkFlagRequired("path") + fsListFiles := &cobra.Command{Use: "list-files ", Short: "List files in a directory", Args: cobra.ExactArgs(1), RunE: runBrowsersFSListFiles} + fsListFiles.Flags().String("path", "", "Absolute directory path") + _ = fsListFiles.MarkFlagRequired("path") + fsMove := &cobra.Command{Use: "move ", Short: "Move or rename a file or directory", Args: cobra.ExactArgs(1), RunE: runBrowsersFSMove} + fsMove.Flags().String("src", "", "Absolute source path") + fsMove.Flags().String("dest", "", "Absolute destination path") + _ = fsMove.MarkFlagRequired("src") + _ = fsMove.MarkFlagRequired("dest") + fsReadFile := &cobra.Command{Use: "read-file ", Short: "Read a file", Args: cobra.ExactArgs(1), RunE: runBrowsersFSReadFile} + fsReadFile.Flags().String("path", "", "Absolute file path") + _ = fsReadFile.MarkFlagRequired("path") + fsReadFile.Flags().StringP("output", "o", "", "Output file path (optional)") + fsSetPerms := &cobra.Command{Use: "set-permissions ", Short: "Set file permissions or ownership", Args: cobra.ExactArgs(1), RunE: runBrowsersFSSetPermissions} + fsSetPerms.Flags().String("path", "", "Absolute path") + fsSetPerms.Flags().String("mode", "", "File mode bits (octal string)") + _ = fsSetPerms.MarkFlagRequired("path") + _ = fsSetPerms.MarkFlagRequired("mode") + fsSetPerms.Flags().String("owner", "", "New owner username or UID") + fsSetPerms.Flags().String("group", "", "New group name or GID") + + // fs upload + fsUpload := &cobra.Command{Use: "upload ", Short: "Upload one or more files", Args: cobra.ExactArgs(1), RunE: runBrowsersFSUpload} + fsUpload.Flags().StringSlice("file", []string{}, "Mapping local:remote (repeatable)") + fsUpload.Flags().String("dest-dir", "", "Destination directory for uploads") + fsUpload.Flags().StringSlice("paths", []string{}, "Local file paths to upload") + + // fs upload-zip + fsUploadZip := &cobra.Command{Use: "upload-zip ", Short: "Upload a zip and extract it", Args: cobra.ExactArgs(1), RunE: runBrowsersFSUploadZip} + fsUploadZip.Flags().String("zip", "", "Local zip file path") + _ = fsUploadZip.MarkFlagRequired("zip") + fsUploadZip.Flags().String("dest-dir", "", "Destination directory to extract to") + _ = fsUploadZip.MarkFlagRequired("dest-dir") + + // fs write-file + fsWriteFile := &cobra.Command{Use: "write-file ", Short: "Write a file from local data", Args: cobra.ExactArgs(1), RunE: runBrowsersFSWriteFile} + fsWriteFile.Flags().String("path", "", "Destination absolute file path") + _ = fsWriteFile.MarkFlagRequired("path") + fsWriteFile.Flags().String("mode", "", "File mode (octal string)") + fsWriteFile.Flags().String("source", "", "Local source file path") + _ = fsWriteFile.MarkFlagRequired("source") + + fsRoot.AddCommand(fsNewDir, fsDelDir, fsDelFile, fsDownloadZip, fsFileInfo, fsListFiles, fsMove, fsReadFile, fsSetPerms, fsUpload, fsUploadZip, fsWriteFile) + browsersCmd.AddCommand(fsRoot) + + // 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") + browsersCreateCmd.Flags().BoolP("headless", "H", false, "Launch browser without GUI access") + browsersCreateCmd.Flags().IntP("timeout", "t", 60, "Timeout in seconds for the browser session") + + // Add flags for delete command + browsersDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + + // no flags for view; it takes a single positional argument +} + +func runBrowsersList(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + b := BrowsersCmd{browsers: &svc} + return b.List(cmd.Context()) +} + +func runBrowsersCreate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + // Get flag values + persistenceID, _ := cmd.Flags().GetString("persistent-id") + stealthVal, _ := cmd.Flags().GetBool("stealth") + headlessVal, _ := cmd.Flags().GetBool("headless") + timeout, _ := cmd.Flags().GetInt("timeout") + + in := BrowsersCreateInput{ + PersistenceID: persistenceID, + TimeoutSeconds: timeout, + Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealthVal}, + Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, + } + + svc := client.Browsers + b := BrowsersCmd{browsers: &svc} + return b.Create(cmd.Context(), in) +} + +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) +} + +func runBrowsersView(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + identifier := args[0] + + in := BrowsersViewInput{Identifier: identifier} + svc := client.Browsers + b := BrowsersCmd{browsers: &svc} + return b.View(cmd.Context(), in) +} + +func runBrowsersLogsStream(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + followVal, _ := cmd.Flags().GetBool("follow") + source, _ := cmd.Flags().GetString("source") + path, _ := cmd.Flags().GetString("path") + supervisor, _ := cmd.Flags().GetString("supervisor-process") + b := BrowsersCmd{browsers: &svc, logs: &svc.Logs} + return b.LogsStream(cmd.Context(), BrowsersLogsStreamInput{ + Identifier: args[0], + Source: source, + Follow: BoolFlag{Set: cmd.Flags().Changed("follow"), Value: followVal}, + Path: path, + SupervisorProcess: supervisor, + }) +} + +func runBrowsersReplaysList(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + b := BrowsersCmd{browsers: &svc, replays: &svc.Replays} + return b.ReplaysList(cmd.Context(), BrowsersReplaysListInput{Identifier: args[0]}) +} + +func runBrowsersReplaysStart(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + fr, _ := cmd.Flags().GetInt("framerate") + md, _ := cmd.Flags().GetInt("max-duration") + b := BrowsersCmd{browsers: &svc, replays: &svc.Replays} + return b.ReplaysStart(cmd.Context(), BrowsersReplaysStartInput{Identifier: args[0], Framerate: fr, MaxDurationSeconds: md}) +} + +func runBrowsersReplaysStop(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + b := BrowsersCmd{browsers: &svc, replays: &svc.Replays} + return b.ReplaysStop(cmd.Context(), BrowsersReplaysStopInput{Identifier: args[0], ReplayID: args[1]}) +} + +func runBrowsersReplaysDownload(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + out, _ := cmd.Flags().GetString("output") + b := BrowsersCmd{browsers: &svc, replays: &svc.Replays} + return b.ReplaysDownload(cmd.Context(), BrowsersReplaysDownloadInput{Identifier: args[0], ReplayID: args[1], Output: out}) +} + +func runBrowsersProcessExec(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + command, _ := cmd.Flags().GetString("command") + argv, _ := cmd.Flags().GetStringSlice("args") + cwd, _ := cmd.Flags().GetString("cwd") + timeout, _ := cmd.Flags().GetInt("timeout") + asUser, _ := cmd.Flags().GetString("as-user") + asRoot, _ := cmd.Flags().GetBool("as-root") + if command == "" && len(args) > 1 { + // Treat trailing args after identifier as a shell command + shellCmd := strings.Join(args[1:], " ") + command = "/bin/bash" + argv = []string{"-c", shellCmd} + } + b := BrowsersCmd{browsers: &svc, process: &svc.Process} + return b.ProcessExec(cmd.Context(), BrowsersProcessExecInput{Identifier: args[0], Command: command, Args: argv, Cwd: cwd, Timeout: timeout, AsUser: asUser, AsRoot: BoolFlag{Set: cmd.Flags().Changed("as-root"), Value: asRoot}}) +} + +func runBrowsersProcessSpawn(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + command, _ := cmd.Flags().GetString("command") + argv, _ := cmd.Flags().GetStringSlice("args") + cwd, _ := cmd.Flags().GetString("cwd") + timeout, _ := cmd.Flags().GetInt("timeout") + asUser, _ := cmd.Flags().GetString("as-user") + asRoot, _ := cmd.Flags().GetBool("as-root") + if command == "" && len(args) > 1 { + shellCmd := strings.Join(args[1:], " ") + command = "/bin/bash" + argv = []string{"-c", shellCmd} + } + b := BrowsersCmd{browsers: &svc, process: &svc.Process} + return b.ProcessSpawn(cmd.Context(), BrowsersProcessSpawnInput{Identifier: args[0], Command: command, Args: argv, Cwd: cwd, Timeout: timeout, AsUser: asUser, AsRoot: BoolFlag{Set: cmd.Flags().Changed("as-root"), Value: asRoot}}) +} + +func runBrowsersProcessKill(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + signal, _ := cmd.Flags().GetString("signal") + b := BrowsersCmd{browsers: &svc, process: &svc.Process} + return b.ProcessKill(cmd.Context(), BrowsersProcessKillInput{Identifier: args[0], ProcessID: args[1], Signal: signal}) +} + +func runBrowsersProcessStatus(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + b := BrowsersCmd{browsers: &svc, process: &svc.Process} + return b.ProcessStatus(cmd.Context(), BrowsersProcessStatusInput{Identifier: args[0], ProcessID: args[1]}) +} + +func runBrowsersProcessStdin(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + data, _ := cmd.Flags().GetString("data-b64") + b := BrowsersCmd{browsers: &svc, process: &svc.Process} + return b.ProcessStdin(cmd.Context(), BrowsersProcessStdinInput{Identifier: args[0], ProcessID: args[1], DataB64: data}) +} + +func runBrowsersProcessStdoutStream(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + b := BrowsersCmd{browsers: &svc, process: &svc.Process} + return b.ProcessStdoutStream(cmd.Context(), BrowsersProcessStdoutStreamInput{Identifier: args[0], ProcessID: args[1]}) +} + +func runBrowsersFSNewDirectory(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + path, _ := cmd.Flags().GetString("path") + mode, _ := cmd.Flags().GetString("mode") + b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} + return b.FSNewDirectory(cmd.Context(), BrowsersFSNewDirInput{Identifier: args[0], Path: path, Mode: mode}) +} + +func runBrowsersFSDeleteDirectory(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + path, _ := cmd.Flags().GetString("path") + b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} + return b.FSDeleteDirectory(cmd.Context(), BrowsersFSDeleteDirInput{Identifier: args[0], Path: path}) +} + +func runBrowsersFSDeleteFile(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + path, _ := cmd.Flags().GetString("path") + b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} + return b.FSDeleteFile(cmd.Context(), BrowsersFSDeleteFileInput{Identifier: args[0], Path: path}) +} + +func runBrowsersFSDownloadDirZip(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + path, _ := cmd.Flags().GetString("path") + out, _ := cmd.Flags().GetString("output") + b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} + return b.FSDownloadDirZip(cmd.Context(), BrowsersFSDownloadDirZipInput{Identifier: args[0], Path: path, Output: out}) +} + +func runBrowsersFSFileInfo(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + path, _ := cmd.Flags().GetString("path") + b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} + return b.FSFileInfo(cmd.Context(), BrowsersFSFileInfoInput{Identifier: args[0], Path: path}) +} + +func runBrowsersFSListFiles(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + path, _ := cmd.Flags().GetString("path") + b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} + return b.FSListFiles(cmd.Context(), BrowsersFSListFilesInput{Identifier: args[0], Path: path}) +} + +func runBrowsersFSMove(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + src, _ := cmd.Flags().GetString("src") + dest, _ := cmd.Flags().GetString("dest") + b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} + return b.FSMove(cmd.Context(), BrowsersFSMoveInput{Identifier: args[0], SrcPath: src, DestPath: dest}) +} + +func runBrowsersFSReadFile(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + path, _ := cmd.Flags().GetString("path") + out, _ := cmd.Flags().GetString("output") + b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} + return b.FSReadFile(cmd.Context(), BrowsersFSReadFileInput{Identifier: args[0], Path: path, Output: out}) +} + +func runBrowsersFSSetPermissions(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + path, _ := cmd.Flags().GetString("path") + mode, _ := cmd.Flags().GetString("mode") + owner, _ := cmd.Flags().GetString("owner") + group, _ := cmd.Flags().GetString("group") + b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} + return b.FSSetPermissions(cmd.Context(), BrowsersFSSetPermsInput{Identifier: args[0], Path: path, Mode: mode, Owner: owner, Group: group}) +} + +func runBrowsersFSUpload(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + fileMaps, _ := cmd.Flags().GetStringSlice("file") + destDir, _ := cmd.Flags().GetString("dest-dir") + paths, _ := cmd.Flags().GetStringSlice("paths") + var mappings []struct { + Local string + Dest string + } + for _, m := range fileMaps { + // format: local:remote + parts := strings.SplitN(m, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + pterm.Error.Printf("invalid --file mapping: %s\n", m) + return nil + } + mappings = append(mappings, struct { + Local string + Dest string + }{Local: parts[0], Dest: parts[1]}) + } + b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} + return b.FSUpload(cmd.Context(), BrowsersFSUploadInput{Identifier: args[0], Mappings: mappings, DestDir: destDir, Paths: paths}) +} + +func runBrowsersFSUploadZip(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + zipPath, _ := cmd.Flags().GetString("zip") + destDir, _ := cmd.Flags().GetString("dest-dir") + b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} + return b.FSUploadZip(cmd.Context(), BrowsersFSUploadZipInput{Identifier: args[0], ZipPath: zipPath, DestDir: destDir}) +} + +func runBrowsersFSWriteFile(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + path, _ := cmd.Flags().GetString("path") + mode, _ := cmd.Flags().GetString("mode") + input, _ := cmd.Flags().GetString("source") + b := BrowsersCmd{browsers: &svc, fs: &svc.Fs} + return b.FSWriteFile(cmd.Context(), BrowsersFSWriteFileInput{Identifier: args[0], DestPath: path, Mode: mode, SourcePath: input}) +} + +func truncateURL(url string, maxLen int) string { + if len(url) <= maxLen { + return url + } + return url[:maxLen-3] + "..." +} + +// resolveBrowserByIdentifier finds a browser by session ID or persistent ID. +func (b BrowsersCmd) resolveBrowserByIdentifier(ctx context.Context, identifier string) (*kernel.BrowserListResponse, error) { + browsers, err := b.browsers.List(ctx) + if err != nil { + return nil, err + } + if browsers == nil { + return nil, nil + } + for _, br := range *browsers { + if br.SessionID == identifier || br.Persistence.ID == identifier { + bCopy := br + return &bCopy, nil + } + } + return nil, nil } diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 0101d01..061b808 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -3,15 +3,22 @@ package cmd import ( "bytes" "context" + "encoding/json" "errors" + "io" + "net/http" "os" + "path/filepath" "strings" "testing" "time" "github.com/onkernel/kernel-go-sdk" "github.com/onkernel/kernel-go-sdk/option" + "github.com/onkernel/kernel-go-sdk/packages/ssestream" + "github.com/onkernel/kernel-go-sdk/shared" "github.com/pterm/pterm" + "github.com/stretchr/testify/assert" ) // outBuf captures pterm output during tests. @@ -93,9 +100,7 @@ func TestBrowsersList_PrintsEmptyMessage(t *testing.T) { _ = b.List(context.Background()) out := outBuf.String() - if !strings.Contains(out, "No running or persistent browsers found") { - t.Fatalf("expected empty message, got: %s", out) - } + assert.Contains(t, out, "No running or persistent browsers found") } func TestBrowsersList_PrintsTableWithRows(t *testing.T) { @@ -128,12 +133,9 @@ func TestBrowsersList_PrintsTableWithRows(t *testing.T) { _ = b.List(context.Background()) out := outBuf.String() - if !strings.Contains(out, "sess-1") || !strings.Contains(out, "sess-2") { - t.Fatalf("expected session IDs in output, got: %s", out) - } - if !strings.Contains(out, "pid-1") { - t.Fatalf("expected persistent ID in output, got: %s", out) - } + assert.Contains(t, out, "sess-1") + assert.Contains(t, out, "sess-2") + assert.Contains(t, out, "pid-1") } func TestBrowsersList_PrintsErrorOnFailure(t *testing.T) { @@ -145,12 +147,10 @@ func TestBrowsersList_PrintsErrorOnFailure(t *testing.T) { }, } b := BrowsersCmd{browsers: fake} - _ = b.List(context.Background()) + err := b.List(context.Background()) - out := outBuf.String() - if !strings.Contains(out, "Failed to list browsers: list failed") { - t.Fatalf("expected error message, got: %s", out) - } + assert.Error(t, err) + assert.Contains(t, err.Error(), "list failed") } func TestBrowsersCreate_PrintsResponse(t *testing.T) { @@ -178,18 +178,14 @@ func TestBrowsersCreate_PrintsResponse(t *testing.T) { _ = b.Create(context.Background(), in) out := outBuf.String() - if !strings.Contains(out, "Session ID") || !strings.Contains(out, "sess-new") { - t.Fatalf("expected session details, got: %s", out) - } - if !strings.Contains(out, "CDP WebSocket URL") || !strings.Contains(out, "ws://cdp-new") { - t.Fatalf("expected cdp url, got: %s", out) - } - if !strings.Contains(out, "Live View URL") || !strings.Contains(out, "http://view-new") { - t.Fatalf("expected live view url, got: %s", out) - } - if !strings.Contains(out, "Persistent ID") || !strings.Contains(out, "pid-new") { - t.Fatalf("expected persistent id, got: %s", out) - } + assert.Contains(t, out, "Session ID") + assert.Contains(t, out, "sess-new") + assert.Contains(t, out, "CDP WebSocket URL") + assert.Contains(t, out, "ws://cdp-new") + assert.Contains(t, out, "Live View URL") + assert.Contains(t, out, "http://view-new") + assert.Contains(t, out, "Persistent ID") + assert.Contains(t, out, "pid-new") } func TestBrowsersCreate_PrintsErrorOnFailure(t *testing.T) { @@ -201,12 +197,10 @@ func TestBrowsersCreate_PrintsErrorOnFailure(t *testing.T) { }, } b := BrowsersCmd{browsers: fake} - _ = b.Create(context.Background(), BrowsersCreateInput{}) + err := b.Create(context.Background(), BrowsersCreateInput{}) - out := outBuf.String() - if !strings.Contains(out, "Failed to create browser: create failed") { - t.Fatalf("expected create error message, got: %s", out) - } + assert.Error(t, err) + assert.Contains(t, err.Error(), "create failed") } func TestBrowsersDelete_SkipConfirm_Success(t *testing.T) { @@ -224,9 +218,7 @@ func TestBrowsersDelete_SkipConfirm_Success(t *testing.T) { _ = b.Delete(context.Background(), BrowsersDeleteInput{Identifier: "any", SkipConfirm: true}) out := outBuf.String() - if !strings.Contains(out, "Successfully deleted (or already absent) browser: any") { - t.Fatalf("expected success message, got: %s", out) - } + assert.Contains(t, out, "Successfully deleted (or already absent) browser: any") } func TestBrowsersDelete_SkipConfirm_Failure(t *testing.T) { @@ -241,12 +233,11 @@ func TestBrowsersDelete_SkipConfirm_Failure(t *testing.T) { }, } b := BrowsersCmd{browsers: fake} - _ = b.Delete(context.Background(), BrowsersDeleteInput{Identifier: "any", SkipConfirm: true}) + err := b.Delete(context.Background(), BrowsersDeleteInput{Identifier: "any", SkipConfirm: true}) - out := outBuf.String() - if !strings.Contains(out, "Failed to delete browser: right failed") && !strings.Contains(out, "Failed to delete browser: left failed") { - t.Fatalf("expected failure message, got: %s", out) - } + assert.Error(t, err) + errMsg := err.Error() + assert.True(t, strings.Contains(errMsg, "right failed") || strings.Contains(errMsg, "left failed"), "expected error message to contain either 'right failed' or 'left failed', got: %s", errMsg) } func TestBrowsersDelete_WithConfirm_NotFound(t *testing.T) { @@ -262,9 +253,7 @@ func TestBrowsersDelete_WithConfirm_NotFound(t *testing.T) { _ = b.Delete(context.Background(), BrowsersDeleteInput{Identifier: "missing", SkipConfirm: false}) out := outBuf.String() - if !strings.Contains(out, "Browser 'missing' not found") { - t.Fatalf("expected not found message, got: %s", out) - } + assert.Contains(t, out, "Browser 'missing' not found") } func TestBrowsersView_ByID_PrintsURL(t *testing.T) { @@ -283,9 +272,7 @@ func TestBrowsersView_ByID_PrintsURL(t *testing.T) { _ = b.View(context.Background(), BrowsersViewInput{Identifier: "abc"}) out := outBuf.String() - if !strings.Contains(out, "http://live-url") { - t.Fatalf("expected live view url, got: %s", out) - } + assert.Contains(t, out, "http://live-url") } func TestBrowsersView_NotFound_ByEither(t *testing.T) { @@ -301,9 +288,7 @@ func TestBrowsersView_NotFound_ByEither(t *testing.T) { _ = b.View(context.Background(), BrowsersViewInput{Identifier: "missing"}) out := outBuf.String() - if !strings.Contains(out, "Browser 'missing' not found") { - t.Fatalf("expected not found message, got: %s", out) - } + assert.Contains(t, out, "Browser 'missing' not found") } func TestBrowsersView_PrintsErrorOnListFailure(t *testing.T) { @@ -315,10 +300,576 @@ func TestBrowsersView_PrintsErrorOnListFailure(t *testing.T) { }, } b := BrowsersCmd{browsers: fake} - _ = b.View(context.Background(), BrowsersViewInput{Identifier: "any"}) + err := b.View(context.Background(), BrowsersViewInput{Identifier: "any"}) - out := outBuf.String() - if !strings.Contains(out, "Failed to list browsers: list error") { - t.Fatalf("expected error message, got: %s", out) + assert.Error(t, err) + assert.Contains(t, err.Error(), "list error") +} + +// --- Fakes for sub-services --- + +type FakeReplaysService struct { + ListFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*[]kernel.BrowserReplayListResponse, error) + DownloadFunc func(ctx context.Context, replayID string, query kernel.BrowserReplayDownloadParams, opts ...option.RequestOption) (*http.Response, error) + StartFunc func(ctx context.Context, id string, body kernel.BrowserReplayStartParams, opts ...option.RequestOption) (*kernel.BrowserReplayStartResponse, error) + StopFunc func(ctx context.Context, replayID string, body kernel.BrowserReplayStopParams, opts ...option.RequestOption) error +} + +func (f *FakeReplaysService) List(ctx context.Context, id string, opts ...option.RequestOption) (*[]kernel.BrowserReplayListResponse, error) { + if f.ListFunc != nil { + return f.ListFunc(ctx, id, opts...) + } + empty := []kernel.BrowserReplayListResponse{} + return &empty, nil +} +func (f *FakeReplaysService) Download(ctx context.Context, replayID string, query kernel.BrowserReplayDownloadParams, opts ...option.RequestOption) (*http.Response, error) { + if f.DownloadFunc != nil { + return f.DownloadFunc(ctx, replayID, query, opts...) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{}}, nil +} +func (f *FakeReplaysService) Start(ctx context.Context, id string, body kernel.BrowserReplayStartParams, opts ...option.RequestOption) (*kernel.BrowserReplayStartResponse, error) { + if f.StartFunc != nil { + return f.StartFunc(ctx, id, body, opts...) + } + return &kernel.BrowserReplayStartResponse{ReplayID: "r-1", ReplayViewURL: "http://view", StartedAt: time.Now()}, nil +} +func (f *FakeReplaysService) Stop(ctx context.Context, replayID string, body kernel.BrowserReplayStopParams, opts ...option.RequestOption) error { + if f.StopFunc != nil { + return f.StopFunc(ctx, replayID, body, opts...) + } + return nil +} + +type FakeFSService struct { + NewDirectoryFunc func(ctx context.Context, id string, body kernel.BrowserFNewDirectoryParams, opts ...option.RequestOption) error + DeleteDirectoryFunc func(ctx context.Context, id string, body kernel.BrowserFDeleteDirectoryParams, opts ...option.RequestOption) error + DeleteFileFunc func(ctx context.Context, id string, body kernel.BrowserFDeleteFileParams, opts ...option.RequestOption) error + DownloadDirZipFunc func(ctx context.Context, id string, query kernel.BrowserFDownloadDirZipParams, opts ...option.RequestOption) (*http.Response, error) + FileInfoFunc func(ctx context.Context, id string, query kernel.BrowserFFileInfoParams, opts ...option.RequestOption) (*kernel.BrowserFFileInfoResponse, error) + ListFilesFunc func(ctx context.Context, id string, query kernel.BrowserFListFilesParams, opts ...option.RequestOption) (*[]kernel.BrowserFListFilesResponse, error) + MoveFunc func(ctx context.Context, id string, body kernel.BrowserFMoveParams, opts ...option.RequestOption) error + ReadFileFunc func(ctx context.Context, id string, query kernel.BrowserFReadFileParams, opts ...option.RequestOption) (*http.Response, error) + SetFilePermissionsFunc func(ctx context.Context, id string, body kernel.BrowserFSetFilePermissionsParams, opts ...option.RequestOption) error + UploadFunc func(ctx context.Context, id string, body kernel.BrowserFUploadParams, opts ...option.RequestOption) error + UploadZipFunc func(ctx context.Context, id string, body kernel.BrowserFUploadZipParams, opts ...option.RequestOption) error + WriteFileFunc func(ctx context.Context, id string, contents io.Reader, body kernel.BrowserFWriteFileParams, opts ...option.RequestOption) error +} + +func (f *FakeFSService) NewDirectory(ctx context.Context, id string, body kernel.BrowserFNewDirectoryParams, opts ...option.RequestOption) error { + if f.NewDirectoryFunc != nil { + return f.NewDirectoryFunc(ctx, id, body, opts...) + } + return nil +} +func (f *FakeFSService) DeleteDirectory(ctx context.Context, id string, body kernel.BrowserFDeleteDirectoryParams, opts ...option.RequestOption) error { + if f.DeleteDirectoryFunc != nil { + return f.DeleteDirectoryFunc(ctx, id, body, opts...) + } + return nil +} +func (f *FakeFSService) DeleteFile(ctx context.Context, id string, body kernel.BrowserFDeleteFileParams, opts ...option.RequestOption) error { + if f.DeleteFileFunc != nil { + return f.DeleteFileFunc(ctx, id, body, opts...) + } + return nil +} +func (f *FakeFSService) DownloadDirZip(ctx context.Context, id string, query kernel.BrowserFDownloadDirZipParams, opts ...option.RequestOption) (*http.Response, error) { + if f.DownloadDirZipFunc != nil { + return f.DownloadDirZipFunc(ctx, id, query, opts...) + } + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/zip"}}, Body: io.NopCloser(strings.NewReader("zip"))}, nil +} +func (f *FakeFSService) FileInfo(ctx context.Context, id string, query kernel.BrowserFFileInfoParams, opts ...option.RequestOption) (*kernel.BrowserFFileInfoResponse, error) { + if f.FileInfoFunc != nil { + return f.FileInfoFunc(ctx, id, query, opts...) + } + return &kernel.BrowserFFileInfoResponse{Path: query.Path, Name: "name", Mode: "-rw-r--r--", IsDir: false, SizeBytes: 5, ModTime: time.Unix(0, 0)}, nil +} +func (f *FakeFSService) ListFiles(ctx context.Context, id string, query kernel.BrowserFListFilesParams, opts ...option.RequestOption) (*[]kernel.BrowserFListFilesResponse, error) { + if f.ListFilesFunc != nil { + return f.ListFilesFunc(ctx, id, query, opts...) + } + files := []kernel.BrowserFListFilesResponse{{Name: "f1", Path: "/f1", Mode: "-rw-r--r--", SizeBytes: 1, ModTime: time.Unix(0, 0)}} + return &files, nil +} +func (f *FakeFSService) Move(ctx context.Context, id string, body kernel.BrowserFMoveParams, opts ...option.RequestOption) error { + if f.MoveFunc != nil { + return f.MoveFunc(ctx, id, body, opts...) + } + return nil +} +func (f *FakeFSService) ReadFile(ctx context.Context, id string, query kernel.BrowserFReadFileParams, opts ...option.RequestOption) (*http.Response, error) { + if f.ReadFileFunc != nil { + return f.ReadFileFunc(ctx, id, query, opts...) + } + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/octet-stream"}}, Body: io.NopCloser(strings.NewReader("content"))}, nil +} +func (f *FakeFSService) SetFilePermissions(ctx context.Context, id string, body kernel.BrowserFSetFilePermissionsParams, opts ...option.RequestOption) error { + if f.SetFilePermissionsFunc != nil { + return f.SetFilePermissionsFunc(ctx, id, body, opts...) + } + return nil +} +func (f *FakeFSService) Upload(ctx context.Context, id string, body kernel.BrowserFUploadParams, opts ...option.RequestOption) error { + if f.UploadFunc != nil { + return f.UploadFunc(ctx, id, body, opts...) + } + return nil +} +func (f *FakeFSService) UploadZip(ctx context.Context, id string, body kernel.BrowserFUploadZipParams, opts ...option.RequestOption) error { + if f.UploadZipFunc != nil { + return f.UploadZipFunc(ctx, id, body, opts...) + } + return nil +} +func (f *FakeFSService) WriteFile(ctx context.Context, id string, contents io.Reader, body kernel.BrowserFWriteFileParams, opts ...option.RequestOption) error { + if f.WriteFileFunc != nil { + return f.WriteFileFunc(ctx, id, contents, body, opts...) + } + return nil +} + +type FakeProcessService struct { + ExecFunc func(ctx context.Context, id string, body kernel.BrowserProcessExecParams, opts ...option.RequestOption) (*kernel.BrowserProcessExecResponse, error) + KillFunc func(ctx context.Context, processID string, params kernel.BrowserProcessKillParams, opts ...option.RequestOption) (*kernel.BrowserProcessKillResponse, error) + SpawnFunc func(ctx context.Context, id string, body kernel.BrowserProcessSpawnParams, opts ...option.RequestOption) (*kernel.BrowserProcessSpawnResponse, error) + StatusFunc func(ctx context.Context, processID string, query kernel.BrowserProcessStatusParams, opts ...option.RequestOption) (*kernel.BrowserProcessStatusResponse, error) + StdinFunc func(ctx context.Context, processID string, params kernel.BrowserProcessStdinParams, opts ...option.RequestOption) (*kernel.BrowserProcessStdinResponse, error) + StdoutStreamFunc func(ctx context.Context, processID string, query kernel.BrowserProcessStdoutStreamParams, opts ...option.RequestOption) *ssestream.Stream[kernel.BrowserProcessStdoutStreamResponse] +} + +func (f *FakeProcessService) Exec(ctx context.Context, id string, body kernel.BrowserProcessExecParams, opts ...option.RequestOption) (*kernel.BrowserProcessExecResponse, error) { + if f.ExecFunc != nil { + return f.ExecFunc(ctx, id, body, opts...) + } + return &kernel.BrowserProcessExecResponse{ExitCode: 0, DurationMs: 10}, nil +} +func (f *FakeProcessService) Kill(ctx context.Context, processID string, params kernel.BrowserProcessKillParams, opts ...option.RequestOption) (*kernel.BrowserProcessKillResponse, error) { + if f.KillFunc != nil { + return f.KillFunc(ctx, processID, params, opts...) + } + return &kernel.BrowserProcessKillResponse{Ok: true}, nil +} +func (f *FakeProcessService) Spawn(ctx context.Context, id string, body kernel.BrowserProcessSpawnParams, opts ...option.RequestOption) (*kernel.BrowserProcessSpawnResponse, error) { + if f.SpawnFunc != nil { + return f.SpawnFunc(ctx, id, body, opts...) + } + return &kernel.BrowserProcessSpawnResponse{ProcessID: "proc-1", Pid: 123, StartedAt: time.Now()}, nil +} +func (f *FakeProcessService) Status(ctx context.Context, processID string, query kernel.BrowserProcessStatusParams, opts ...option.RequestOption) (*kernel.BrowserProcessStatusResponse, error) { + if f.StatusFunc != nil { + return f.StatusFunc(ctx, processID, query, opts...) + } + return &kernel.BrowserProcessStatusResponse{State: kernel.BrowserProcessStatusResponseStateRunning, CPUPct: 1.5, MemBytes: 2048, ExitCode: 0}, nil +} +func (f *FakeProcessService) Stdin(ctx context.Context, processID string, params kernel.BrowserProcessStdinParams, opts ...option.RequestOption) (*kernel.BrowserProcessStdinResponse, error) { + if f.StdinFunc != nil { + return f.StdinFunc(ctx, processID, params, opts...) + } + return &kernel.BrowserProcessStdinResponse{WrittenBytes: int64(len(params.DataB64))}, nil +} +func (f *FakeProcessService) StdoutStreamStreaming(ctx context.Context, processID string, query kernel.BrowserProcessStdoutStreamParams, opts ...option.RequestOption) *ssestream.Stream[kernel.BrowserProcessStdoutStreamResponse] { + if f.StdoutStreamFunc != nil { + return f.StdoutStreamFunc(ctx, processID, query, opts...) } + return makeStream([]kernel.BrowserProcessStdoutStreamResponse{{Stream: kernel.BrowserProcessStdoutStreamResponseStreamStdout, DataB64: "aGVsbG8=", Event: ""}, {Event: "exit", ExitCode: 0}}) +} + +type FakeLogService struct { + StreamFunc func(ctx context.Context, id string, query kernel.BrowserLogStreamParams, opts ...option.RequestOption) *ssestream.Stream[shared.LogEvent] +} + +func (f *FakeLogService) StreamStreaming(ctx context.Context, id string, query kernel.BrowserLogStreamParams, opts ...option.RequestOption) *ssestream.Stream[shared.LogEvent] { + if f.StreamFunc != nil { + return f.StreamFunc(ctx, id, query, opts...) + } + now := time.Now() + return makeStream([]shared.LogEvent{{Message: "m1", Timestamp: now}, {Message: "m2", Timestamp: now}}) +} + +// --- Helpers for SSE streams --- + +type testDecoder struct { + data [][]byte + idx int +} + +func (d *testDecoder) Event() ssestream.Event { return ssestream.Event{Data: d.data[d.idx-1]} } +func (d *testDecoder) Next() bool { + if d.idx >= len(d.data) { + return false + } + d.idx++ + return true +} +func (d *testDecoder) Close() error { return nil } +func (d *testDecoder) Err() error { return nil } + +func makeStream[T any](vals []T) *ssestream.Stream[T] { + var events [][]byte + for _, v := range vals { + b, _ := json.Marshal(v) + events = append(events, b) + } + return ssestream.NewStream[T](&testDecoder{data: events}, nil) +} + +// --- Tests for Logs --- + +func TestBrowsersLogsStream_PrintsEvents(t *testing.T) { + setupStdoutCapture(t) + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, logs: &FakeLogService{}} + _ = b.LogsStream(context.Background(), BrowsersLogsStreamInput{Identifier: "id", Source: string(kernel.BrowserLogStreamParamsSourcePath), Follow: BoolFlag{Set: true, Value: true}, Path: "/var/log.txt"}) + out := outBuf.String() + assert.Contains(t, out, "m1") + assert.Contains(t, out, "m2") +} + +// --- Tests for Replays --- + +func TestBrowsersReplaysList_PrintsRows(t *testing.T) { + setupStdoutCapture(t) + created := time.Unix(0, 0) + replays := []kernel.BrowserReplayListResponse{{ReplayID: "r1", StartedAt: created, FinishedAt: created, ReplayViewURL: "http://v"}} + fake := &FakeReplaysService{ListFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*[]kernel.BrowserReplayListResponse, error) { + return &replays, nil + }} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, replays: fake} + _ = b.ReplaysList(context.Background(), BrowsersReplaysListInput{Identifier: "id"}) + out := outBuf.String() + assert.Contains(t, out, "r1") + assert.Contains(t, out, "http://v") +} + +func TestBrowsersReplaysStart_PrintsInfo(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeReplaysService{StartFunc: func(ctx context.Context, id string, body kernel.BrowserReplayStartParams, opts ...option.RequestOption) (*kernel.BrowserReplayStartResponse, error) { + return &kernel.BrowserReplayStartResponse{ReplayID: "rid", ReplayViewURL: "http://view", StartedAt: time.Unix(0, 0)}, nil + }} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, replays: fake} + _ = b.ReplaysStart(context.Background(), BrowsersReplaysStartInput{Identifier: "id", Framerate: 30, MaxDurationSeconds: 60}) + out := outBuf.String() + assert.Contains(t, out, "rid") + assert.Contains(t, out, "http://view") +} + +func TestBrowsersReplaysStop_PrintsSuccess(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeReplaysService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, replays: fake} + _ = b.ReplaysStop(context.Background(), BrowsersReplaysStopInput{Identifier: "id", ReplayID: "rid"}) + out := outBuf.String() + assert.Contains(t, out, "Stopped replay rid") +} + +func TestBrowsersReplaysDownload_SavesFile(t *testing.T) { + setupStdoutCapture(t) + dir := t.TempDir() + outPath := filepath.Join(dir, "replay.mp4") + fake := &FakeReplaysService{DownloadFunc: func(ctx context.Context, replayID string, query kernel.BrowserReplayDownloadParams, opts ...option.RequestOption) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"video/mp4"}}, Body: io.NopCloser(strings.NewReader("mp4data"))}, nil + }} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, replays: fake} + _ = b.ReplaysDownload(context.Background(), BrowsersReplaysDownloadInput{Identifier: "id", ReplayID: "rid", Output: outPath}) + data, err := os.ReadFile(outPath) + assert.NoError(t, err) + assert.Equal(t, "mp4data", string(data)) +} + +// --- Tests for Process --- + +func TestBrowsersProcessExec_PrintsSummary(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeProcessService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, process: fake} + _ = b.ProcessExec(context.Background(), BrowsersProcessExecInput{Identifier: "id", Command: "echo"}) + out := outBuf.String() + assert.Contains(t, out, "Exit Code") + assert.Contains(t, out, "Duration") +} + +func TestBrowsersProcessSpawn_PrintsInfo(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeProcessService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, process: fake} + _ = b.ProcessSpawn(context.Background(), BrowsersProcessSpawnInput{Identifier: "id", Command: "sleep"}) + out := outBuf.String() + assert.Contains(t, out, "Process ID") + assert.Contains(t, out, "PID") +} + +func TestBrowsersProcessKill_PrintsSuccess(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeProcessService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, process: fake} + _ = b.ProcessKill(context.Background(), BrowsersProcessKillInput{Identifier: "id", ProcessID: "proc", Signal: "TERM"}) + out := outBuf.String() + assert.Contains(t, out, "Sent TERM to process proc") +} + +func TestBrowsersProcessStatus_PrintsFields(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeProcessService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, process: fake} + _ = b.ProcessStatus(context.Background(), BrowsersProcessStatusInput{Identifier: "id", ProcessID: "proc"}) + out := outBuf.String() + assert.Contains(t, out, "State") + assert.Contains(t, out, "CPU %") + assert.Contains(t, out, "Mem Bytes") +} + +func TestBrowsersProcessStdin_PrintsSuccess(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeProcessService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, process: fake} + _ = b.ProcessStdin(context.Background(), BrowsersProcessStdinInput{Identifier: "id", ProcessID: "proc", DataB64: "ZGF0YQ=="}) + out := outBuf.String() + assert.Contains(t, out, "Wrote to stdin") +} + +func TestBrowsersProcessStdoutStream_PrintsExit(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeProcessService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, process: fake} + _ = b.ProcessStdoutStream(context.Background(), BrowsersProcessStdoutStreamInput{Identifier: "id", ProcessID: "proc"}) + out := outBuf.String() + assert.Contains(t, out, "process exited with code 0") +} + +// --- Tests for FS --- + +func TestBrowsersFSNewDirectory_PrintsSuccess(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeFSService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, fs: fake} + _ = b.FSNewDirectory(context.Background(), BrowsersFSNewDirInput{Identifier: "id", Path: "/tmp/x"}) + out := outBuf.String() + assert.Contains(t, out, "Created directory /tmp/x") +} + +func TestBrowsersFSDeleteDirectory_PrintsSuccess(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeFSService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, fs: fake} + _ = b.FSDeleteDirectory(context.Background(), BrowsersFSDeleteDirInput{Identifier: "id", Path: "/tmp/x"}) + out := outBuf.String() + assert.Contains(t, out, "Deleted directory /tmp/x") +} + +func TestBrowsersFSDeleteFile_PrintsSuccess(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeFSService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, fs: fake} + _ = b.FSDeleteFile(context.Background(), BrowsersFSDeleteFileInput{Identifier: "id", Path: "/tmp/file"}) + out := outBuf.String() + assert.Contains(t, out, "Deleted file /tmp/file") +} + +func TestBrowsersFSDownloadDirZip_SavesFile(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "out.zip") + fake := &FakeFSService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, fs: fake} + _ = b.FSDownloadDirZip(context.Background(), BrowsersFSDownloadDirZipInput{Identifier: "id", Path: "/tmp", Output: outPath}) + data, err := os.ReadFile(outPath) + assert.NoError(t, err) + assert.Equal(t, "zip", string(data)) +} + +func TestBrowsersFSFileInfo_PrintsFields(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeFSService{FileInfoFunc: func(ctx context.Context, id string, query kernel.BrowserFFileInfoParams, opts ...option.RequestOption) (*kernel.BrowserFFileInfoResponse, error) { + return &kernel.BrowserFFileInfoResponse{Path: "/tmp/a", Name: "a", Mode: "-rw-r--r--", IsDir: false, SizeBytes: 1, ModTime: time.Unix(0, 0)}, nil + }} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, fs: fake} + _ = b.FSFileInfo(context.Background(), BrowsersFSFileInfoInput{Identifier: "id", Path: "/tmp/a"}) + out := outBuf.String() + assert.Contains(t, out, "Path") + assert.Contains(t, out, "/tmp/a") +} + +func TestBrowsersFSListFiles_PrintsRows(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeFSService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, fs: fake} + _ = b.FSListFiles(context.Background(), BrowsersFSListFilesInput{Identifier: "id", Path: "/"}) + out := outBuf.String() + assert.Contains(t, out, "f1") + assert.Contains(t, out, "/f1") +} + +func TestBrowsersFSMove_PrintsSuccess(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeFSService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, fs: fake} + _ = b.FSMove(context.Background(), BrowsersFSMoveInput{Identifier: "id", SrcPath: "/a", DestPath: "/b"}) + out := outBuf.String() + assert.Contains(t, out, "Moved /a -> /b") +} + +func TestBrowsersFSReadFile_SavesFile(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "file.txt") + fake := &FakeFSService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, fs: fake} + _ = b.FSReadFile(context.Background(), BrowsersFSReadFileInput{Identifier: "id", Path: "/tmp/x", Output: outPath}) + data, err := os.ReadFile(outPath) + assert.NoError(t, err) + assert.Equal(t, "content", string(data)) +} + +func TestBrowsersFSSetPermissions_PrintsSuccess(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeFSService{} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, fs: fake} + _ = b.FSSetPermissions(context.Background(), BrowsersFSSetPermsInput{Identifier: "id", Path: "/tmp/a", Mode: "644"}) + out := outBuf.String() + assert.Contains(t, out, "Updated permissions for /tmp/a") +} + +func TestBrowsersFSUpload_MappingAndDestDir_Success(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserFUploadParams + fake := &FakeFSService{UploadFunc: func(ctx context.Context, id string, body kernel.BrowserFUploadParams, opts ...option.RequestOption) error { + captured = body + return nil + }} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, fs: fake} + in := BrowsersFSUploadInput{Identifier: "id", Mappings: []struct { + Local string + Dest string + }{{Local: __writeTempFile(t, "a"), Dest: "/remote/a"}}, DestDir: "/remote/dir", Paths: []string{__writeTempFile(t, "b")}} + _ = b.FSUpload(context.Background(), in) + out := outBuf.String() + assert.Contains(t, out, "Uploaded") + assert.Equal(t, 2, len(captured.Files)) +} + +func TestBrowsersFSUploadZip_Success(t *testing.T) { + setupStdoutCapture(t) + z := __writeTempFile(t, "zipdata") + fake := &FakeFSService{UploadZipFunc: func(ctx context.Context, id string, body kernel.BrowserFUploadZipParams, opts ...option.RequestOption) error { + return nil + }} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, fs: fake} + _ = b.FSUploadZip(context.Background(), BrowsersFSUploadZipInput{Identifier: "id", ZipPath: z, DestDir: "/dst"}) + out := outBuf.String() + assert.Contains(t, out, "Uploaded zip") +} + +func TestBrowsersFSWriteFile_FromBase64_And_FromInput(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeFSService{WriteFileFunc: func(ctx context.Context, id string, contents io.Reader, body kernel.BrowserFWriteFileParams, opts ...option.RequestOption) error { + return nil + }} + fakeBrowsers := &FakeBrowsersService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.BrowserListResponse, error) { + rows := []kernel.BrowserListResponse{{SessionID: "id"}} + return &rows, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, fs: fake} + // input mode + p := __writeTempFile(t, "hello") + _ = b.FSWriteFile(context.Background(), BrowsersFSWriteFileInput{Identifier: "id", DestPath: "/y", SourcePath: p, Mode: "644"}) + out := outBuf.String() + assert.Contains(t, out, "Wrote file to /y") +} + +// helper to create temp file with contents +func __writeTempFile(t *testing.T, data string) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "cli-test-*") + assert.NoError(t, err) + _, err = f.WriteString(data) + assert.NoError(t, err) + _ = f.Close() + return f.Name() } diff --git a/cmd/deploy.go b/cmd/deploy.go index 2fdd3af..290ec9c 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -101,7 +101,7 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { EnvVars: envVars, }, option.WithMaxRetries(0)) if err != nil { - return &util.CleanedUpSdkError{Err: err} + return util.CleanedUpSdkError{Err: err} } // Follow deployment events via SSE diff --git a/cmd/root.go b/cmd/root.go index 9f7a3e0..ee76fa4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -75,10 +75,11 @@ func getKernelClient(cmd *cobra.Command) kernel.Client { // isAuthExempt returns true if the command or any of its parents should skip auth. func isAuthExempt(cmd *cobra.Command) bool { + // bare root command does not need auth + if cmd == rootCmd { + return true + } for c := cmd; c != nil; c = c.Parent() { - if c == rootCmd { - return true - } switch c.Name() { case "login", "logout", "auth", "help", "completion": return true diff --git a/cmd/table.go b/cmd/table.go new file mode 100644 index 0000000..d00906b --- /dev/null +++ b/cmd/table.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "strings" + "unicode/utf8" + + "github.com/pterm/pterm" +) + +// printTableNoPad renders a table similar to pterm.DefaultTable, but it avoids +// adding trailing padding spaces after the last column and does not add blank +// padded lines to match multi-line cells in other columns. The last column may +// contain multi-line content which will be printed as-is on following lines. +func printTableNoPad(data pterm.TableData, hasHeader bool) { + if len(data) == 0 { + return + } + + // Determine number of columns from the first row + numCols := len(data[0]) + if numCols == 0 { + return + } + + // Pre-compute max width per column for all but the last column + maxColWidths := make([]int, numCols) + for _, row := range data { + for colIdx := 0; colIdx < numCols && colIdx < len(row); colIdx++ { + if colIdx == numCols-1 { + continue + } + for _, line := range strings.Split(row[colIdx], "\n") { + if w := utf8.RuneCountInString(line); w > maxColWidths[colIdx] { + maxColWidths[colIdx] = w + } + } + } + } + + var b strings.Builder + sep := pterm.DefaultTable.Separator + sepStyled := pterm.ThemeDefault.TableSeparatorStyle.Sprint(sep) + + renderRow := func(row []string, styleHeader bool) { + // Build first-line-only for non-last columns; last column is full string + firstLineParts := make([]string, 0, numCols) + for colIdx := 0; colIdx < numCols; colIdx++ { + var cell string + if colIdx < len(row) { + cell = row[colIdx] + } + + if colIdx < numCols-1 { + // Only the first line for non-last columns + lines := strings.Split(cell, "\n") + first := "" + if len(lines) > 0 { + first = lines[0] + } + padCount := maxColWidths[colIdx] - utf8.RuneCountInString(first) + if padCount < 0 { + padCount = 0 + } + firstLineParts = append(firstLineParts, first+strings.Repeat(" ", padCount)) + } else { + // Last column: render the first line now; remaining lines after + lines := strings.Split(cell, "\n") + if len(lines) > 0 { + firstLineParts = append(firstLineParts, lines[0]) + } else { + firstLineParts = append(firstLineParts, "") + } + } + } + + line := strings.Join(firstLineParts[:numCols-1], sepStyled) + if numCols > 1 { + if line != "" { + line += sepStyled + } + line += firstLineParts[numCols-1] + } + + if styleHeader { + b.WriteString(pterm.ThemeDefault.TableHeaderStyle.Sprint(line)) + } else { + b.WriteString(line) + } + b.WriteString("\n") + + // Print remaining lines from the last column without alignment padding + if numCols > 0 { + var lastCell string + if len(row) >= numCols { + lastCell = row[numCols-1] + } + lines := strings.Split(lastCell, "\n") + if len(lines) > 1 { + rest := strings.Join(lines[1:], "\n") + if rest != "" { + b.WriteString(rest) + b.WriteString("\n") + } + } + } + } + + for idx, row := range data { + renderRow(row, hasHeader && idx == 0) + } + + pterm.Print(b.String()) +} diff --git a/go.mod b/go.mod index c158fdf..cf07c5f 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,12 @@ 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.9.1 + github.com/onkernel/kernel-go-sdk v0.10.1-0.20250827184402-40919678c68e github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 github.com/spf13/cobra v1.9.1 + github.com/stretchr/testify v1.11.0 github.com/zalando/go-keyring v0.2.6 golang.org/x/oauth2 v0.30.0 ) @@ -31,6 +32,7 @@ require ( github.com/containerd/console v1.0.3 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gookit/color v1.5.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -42,6 +44,7 @@ require ( github.com/muesli/mango-cobra v1.2.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/tidwall/gjson v1.18.0 // indirect @@ -53,4 +56,5 @@ require ( golang.org/x/sys v0.33.0 // indirect golang.org/x/term v0.26.0 // indirect golang.org/x/text v0.24.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 48803b6..a713339 100644 --- a/go.sum +++ b/go.sum @@ -69,8 +69,10 @@ github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuOb github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= @@ -89,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.9.1 h1:Mt9YYR4tSW36ZuhmGstEZy6Wo/kvKtJVajCRujjvnck= -github.com/onkernel/kernel-go-sdk v0.9.1/go.mod h1:q7wsAf+yjpY+w8jbAMciWCtCM0ZUxiw/5o2MSPTZS9E= +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= @@ -122,8 +124,8 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= +github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -190,6 +192,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/util/errors.go b/pkg/util/errors.go index 7e4aa18..45a4843 100644 --- a/pkg/util/errors.go +++ b/pkg/util/errors.go @@ -2,35 +2,40 @@ package util import ( "encoding/json" + "errors" "fmt" + "io" "github.com/onkernel/kernel-go-sdk" ) +// CleanedUpSdkError extracts a message field from the raw JSON resposne. +// This is the convention we use in the API for error response bodies (400s and 500s) type CleanedUpSdkError struct { - Err error - Message string + Err error } -var _ error = &CleanedUpSdkError{} +var _ error = CleanedUpSdkError{} -func (e *CleanedUpSdkError) Error() string { - if e.Message != "" { - return e.Message - } - if _, ok := e.Err.(*kernel.Error); ok { - // assume we send back JSON with an "error" field, and just show that +func (e CleanedUpSdkError) Error() string { + var kerror *kernel.Error + if errors.As(e.Err, &kerror) { var m map[string]interface{} - if err := json.Unmarshal([]byte(e.Err.(*kernel.Error).RawJSON()), &m); err != nil { - return e.Err.Error() - } - if v, ok := m["error"]; ok { - return fmt.Sprintf("%v", v) + if err := json.Unmarshal([]byte(kerror.RawJSON()), &m); err == nil { + message, _ := m["message"].(string) + code, _ := m["code"].(string) + return fmt.Sprintf("%s: %s", code, message) + } else if kerror.Response != nil && kerror.Response.Body != nil { + // try response body as text + body, err := io.ReadAll(kerror.Response.Body) + if err == nil && len(body) > 0 { + return string(body) + } } } return e.Err.Error() } -func (e *CleanedUpSdkError) Unwrap() error { +func (e CleanedUpSdkError) Unwrap() error { return e.Err } diff --git a/tapes/demo.gif b/tapes/demo.gif new file mode 100644 index 0000000..41ba501 Binary files /dev/null and b/tapes/demo.gif differ diff --git a/tapes/demo.tape b/tapes/demo.tape new file mode 100644 index 0000000..56c6017 --- /dev/null +++ b/tapes/demo.tape @@ -0,0 +1,69 @@ +# VHS documentation +# +# Output: +# Output .gif Create a GIF output at the given +# Output .mp4 Create an MP4 output at the given +# Output .webm Create a WebM output at the given +# +# Require: +# Require Ensure a program is on the $PATH to proceed +# +# Settings: +# Set FontSize Set the font size of the terminal +# Set FontFamily Set the font family of the terminal +# Set Height Set the height of the terminal +# Set Width Set the width of the terminal +# Set LetterSpacing Set the font letter spacing (tracking) +# Set LineHeight Set the font line height +# Set LoopOffset % Set the starting frame offset for the GIF loop +# Set Theme Set the theme of the terminal +# Set Padding Set the padding of the terminal +# Set Framerate Set the framerate of the recording +# Set PlaybackSpeed Set the playback speed of the recording +# Set MarginFill Set the file or color the margin will be filled with. +# Set Margin Set the size of the margin. Has no effect if MarginFill isn't set. +# Set BorderRadius Set terminal border radius, in pixels. +# Set WindowBar Set window bar type. (one of: Rings, RingsRight, Colorful, ColorfulRight) +# Set WindowBarSize Set window bar size, in pixels. Default is 40. +# Set TypingSpeed