From 1a5aced5209a0ff7ab7eb5454f18d49f2d906e92 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Wed, 26 Nov 2025 10:21:26 -0500 Subject: [PATCH 1/7] Ignore .env --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e8d1ec6..70126d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .prism.log dist/ /hypeman +.env From 0542167a440856e70792e10a84a314d125f4a6fb Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Wed, 26 Nov 2025 10:48:22 -0500 Subject: [PATCH 2/7] Generate exec feature --- .gitignore | 1 + cmd/hypeman/main.go | 6 + go.mod | 1 + go.sum | 2 + pkg/cmd/cmd.go | 1 + pkg/cmd/exec.go | 338 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 349 insertions(+) create mode 100644 pkg/cmd/exec.go diff --git a/.gitignore b/.gitignore index 70126d3..cd3cb15 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ dist/ /hypeman .env +hypeman/** diff --git a/cmd/hypeman/main.go b/cmd/hypeman/main.go index 15f6d40..05214d5 100644 --- a/cmd/hypeman/main.go +++ b/cmd/hypeman/main.go @@ -17,6 +17,12 @@ import ( func main() { app := cmd.Command if err := app.Run(context.Background(), os.Args); err != nil { + // Handle exec exit codes specially - exit with the command's exit code + var execErr *cmd.ExecExitError + if errors.As(err, &execErr) { + os.Exit(execErr.Code) + } + var apierr *hypeman.Error if errors.As(err, &apierr) { fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode)) diff --git a/go.mod b/go.mod index f0be48a..a0e8544 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index da50da2..63db767 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8= github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 12584bc..f90f277 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -67,6 +67,7 @@ func init() { }, }, Commands: []*cli.Command{ + &execCmd, { Name: "health", Category: "API RESOURCE", diff --git a/pkg/cmd/exec.go b/pkg/cmd/exec.go new file mode 100644 index 0000000..dd02e8e --- /dev/null +++ b/pkg/cmd/exec.go @@ -0,0 +1,338 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/gorilla/websocket" + "github.com/urfave/cli/v3" + "golang.org/x/term" +) + +// ExecExitError is returned when exec completes with a non-zero exit code +type ExecExitError struct { + Code int +} + +func (e *ExecExitError) Error() string { + return fmt.Sprintf("exec exited with code %d", e.Code) +} + +// execRequest represents the JSON body for exec requests +type execRequest struct { + Command []string `json:"command"` + TTY bool `json:"tty"` + Env map[string]string `json:"env,omitempty"` + Cwd string `json:"cwd,omitempty"` + Timeout int32 `json:"timeout,omitempty"` +} + +var execCmd = cli.Command{ + Name: "exec", + Usage: "Execute a command in a running instance", + ArgsUsage: " [-- command...]", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "it", + Aliases: []string{"i", "t"}, + Usage: "Enable interactive TTY mode", + }, + &cli.BoolFlag{ + Name: "no-tty", + Aliases: []string{"T"}, + Usage: "Disable TTY allocation", + }, + &cli.StringSliceFlag{ + Name: "env", + Aliases: []string{"e"}, + Usage: "Set environment variable (KEY=VALUE, can be repeated)", + }, + &cli.StringFlag{ + Name: "cwd", + Usage: "Working directory inside the instance", + }, + &cli.IntFlag{ + Name: "timeout", + Usage: "Execution timeout in seconds (0 = no timeout)", + }, + }, + Action: handleExec, + HideHelpCommand: true, +} + +func handleExec(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance ID required\nUsage: hypeman exec [flags] [-- command...]") + } + + instanceID := args[0] + var command []string + + // Parse command after -- separator or remaining args + if len(args) > 1 { + command = args[1:] + } + + // Determine TTY mode + tty := true // default + if cmd.Bool("no-tty") { + tty = false + } else if cmd.Bool("it") { + tty = true + } else { + // Auto-detect: enable TTY if stdin and stdout are terminals + tty = term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) + } + + // Parse environment variables + env := make(map[string]string) + for _, e := range cmd.StringSlice("env") { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + env[parts[0]] = parts[1] + } else { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed env var: %s\n", e) + } + } + + // Build exec request + execReq := execRequest{ + Command: command, + TTY: tty, + } + if len(env) > 0 { + execReq.Env = env + } + if cwd := cmd.String("cwd"); cwd != "" { + execReq.Cwd = cwd + } + if timeout := cmd.Int("timeout"); timeout > 0 { + execReq.Timeout = int32(timeout) + } + + reqBody, err := json.Marshal(execReq) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + // Get base URL and API key + baseURL := cmd.Root().String("base-url") + if baseURL == "" { + baseURL = os.Getenv("HYPEMAN_BASE_URL") + } + if baseURL == "" { + baseURL = "https://api.onkernel.com" + } + + apiKey := os.Getenv("HYPEMAN_API_KEY") + if apiKey == "" { + return fmt.Errorf("HYPEMAN_API_KEY environment variable required") + } + + // Build WebSocket URL + u, err := url.Parse(baseURL) + if err != nil { + return fmt.Errorf("invalid base URL: %w", err) + } + u.Path = fmt.Sprintf("/instances/%s/exec", instanceID) + + // Convert scheme to WebSocket + switch u.Scheme { + case "https": + u.Scheme = "wss" + case "http": + u.Scheme = "ws" + } + + // Connect WebSocket with auth header + headers := http.Header{} + headers.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) + + dialer := &websocket.Dialer{} + ws, resp, err := dialer.DialContext(ctx, u.String(), headers) + if err != nil { + if resp != nil { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("websocket connect failed (HTTP %d): %s", resp.StatusCode, string(body)) + } + return fmt.Errorf("websocket connect failed: %w", err) + } + defer ws.Close() + + // Send JSON request as first message + if err := ws.WriteMessage(websocket.TextMessage, reqBody); err != nil { + return fmt.Errorf("failed to send exec request: %w", err) + } + + // Run interactive or non-interactive mode + var exitCode int + if tty { + exitCode, err = runExecInteractive(ws) + } else { + exitCode, err = runExecNonInteractive(ws) + } + + if err != nil { + return err + } + + if exitCode != 0 { + return &ExecExitError{Code: exitCode} + } + + return nil +} + +func runExecInteractive(ws *websocket.Conn) (int, error) { + // Put terminal in raw mode + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return 255, fmt.Errorf("failed to set raw mode: %w", err) + } + defer term.Restore(int(os.Stdin.Fd()), oldState) + + // Handle signals gracefully + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigCh) + + errCh := make(chan error, 2) + exitCodeCh := make(chan int, 1) + + // Forward stdin to WebSocket + go func() { + buf := make([]byte, 32*1024) + for { + n, err := os.Stdin.Read(buf) + if err != nil { + if err != io.EOF { + errCh <- fmt.Errorf("stdin read error: %w", err) + } + return + } + if n > 0 { + if err := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { + errCh <- fmt.Errorf("websocket write error: %w", err) + return + } + } + } + }() + + // Forward WebSocket to stdout + go func() { + for { + msgType, message, err := ws.ReadMessage() + if err != nil { + if !websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + exitCodeCh <- 0 + } + return + } + + // Check for exit code message + if msgType == websocket.TextMessage && bytes.Contains(message, []byte("exitCode")) { + var exitMsg struct { + ExitCode int `json:"exitCode"` + } + if json.Unmarshal(message, &exitMsg) == nil { + exitCodeCh <- exitMsg.ExitCode + return + } + } + + // Write binary messages to stdout (actual output) + if msgType == websocket.BinaryMessage { + os.Stdout.Write(message) + } + } + }() + + select { + case err := <-errCh: + return 255, err + case exitCode := <-exitCodeCh: + return exitCode, nil + case <-sigCh: + return 130, nil // 128 + SIGINT + } +} + +func runExecNonInteractive(ws *websocket.Conn) (int, error) { + errCh := make(chan error, 2) + exitCodeCh := make(chan int, 1) + doneCh := make(chan struct{}) + + // Forward stdin to WebSocket + go func() { + buf := make([]byte, 32*1024) + for { + n, err := os.Stdin.Read(buf) + if err != nil { + if err != io.EOF { + errCh <- fmt.Errorf("stdin read error: %w", err) + } + return + } + if n > 0 { + if err := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { + errCh <- fmt.Errorf("websocket write error: %w", err) + return + } + } + } + }() + + // Forward WebSocket to stdout + go func() { + defer close(doneCh) + for { + msgType, message, err := ws.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) || + err == io.EOF { + exitCodeCh <- 0 + return + } + errCh <- fmt.Errorf("websocket read error: %w", err) + return + } + + // Check for exit code message + if msgType == websocket.TextMessage && bytes.Contains(message, []byte("exitCode")) { + var exitMsg struct { + ExitCode int `json:"exitCode"` + } + if json.Unmarshal(message, &exitMsg) == nil { + exitCodeCh <- exitMsg.ExitCode + return + } + } + + // Write to stdout (binary messages contain actual output) + if msgType == websocket.BinaryMessage { + os.Stdout.Write(message) + } + } + }() + + select { + case err := <-errCh: + return 255, err + case exitCode := <-exitCodeCh: + return exitCode, nil + case <-doneCh: + return 0, nil + } +} + From cbb8eb88e020af3c6d33d7d7dcc1258164b7f6aa Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Wed, 26 Nov 2025 12:06:20 -0500 Subject: [PATCH 3/7] Add docker like aliases --- README.md | 29 +++++++-- pkg/cmd/cmd.go | 4 ++ pkg/cmd/exec.go | 2 +- pkg/cmd/format.go | 148 ++++++++++++++++++++++++++++++++++++++++++++++ pkg/cmd/logs.go | 65 ++++++++++++++++++++ pkg/cmd/ps.go | 84 ++++++++++++++++++++++++++ pkg/cmd/pull.go | 54 +++++++++++++++++ pkg/cmd/run.go | 141 +++++++++++++++++++++++++++++++++++++++++++ pkg/cmd/util.go | 8 ++- 9 files changed, 525 insertions(+), 10 deletions(-) create mode 100644 pkg/cmd/format.go create mode 100644 pkg/cmd/logs.go create mode 100644 pkg/cmd/ps.go create mode 100644 pkg/cmd/pull.go create mode 100644 pkg/cmd/run.go diff --git a/README.md b/README.md index 0ff4bf2..5e63049 100644 --- a/README.md +++ b/README.md @@ -33,18 +33,35 @@ go run cmd/hypeman/main.go ## Usage -The CLI follows a resource-based command structure: - ```sh -hypeman [resource] [command] [flags] -``` +# Pull an image +hypeman pull nginx:alpine -```sh -hypeman health check +# Run an instance (auto-pulls image if needed) +hypeman run nginx:alpine +hypeman run --name my-app -e PORT=3000 nginx:alpine + +# List running instances +hypeman ps +hypeman ps -a # show all instances + +# View logs +hypeman logs +hypeman logs -f # follow logs + +# Execute a command in a running instance +hypeman exec -- /bin/sh +hypeman exec -it # interactive shell ``` For details about specific commands, use the `--help` flag. +The CLI also provides resource-based commands for more advanced usage: + +```sh +hypeman [resource] [command] [flags] +``` + ## Global Flags - `--debug` - Enable debug logging (includes HTTP request/response details) diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index f90f277..a115c0b 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -68,6 +68,10 @@ func init() { }, Commands: []*cli.Command{ &execCmd, + &pullCmd, + &runCmd, + &psCmd, + &logsCmd, { Name: "health", Category: "API RESOURCE", diff --git a/pkg/cmd/exec.go b/pkg/cmd/exec.go index dd02e8e..9a073cf 100644 --- a/pkg/cmd/exec.go +++ b/pkg/cmd/exec.go @@ -131,7 +131,7 @@ func handleExec(ctx context.Context, cmd *cli.Command) error { baseURL = os.Getenv("HYPEMAN_BASE_URL") } if baseURL == "" { - baseURL = "https://api.onkernel.com" + baseURL = "http://localhost:8080" } apiKey := os.Getenv("HYPEMAN_API_KEY") diff --git a/pkg/cmd/format.go b/pkg/cmd/format.go new file mode 100644 index 0000000..44e8e80 --- /dev/null +++ b/pkg/cmd/format.go @@ -0,0 +1,148 @@ +package cmd + +import ( + "fmt" + "io" + "strings" + "time" +) + +// TableWriter provides simple table formatting for CLI output +type TableWriter struct { + w io.Writer + headers []string + widths []int + rows [][]string +} + +// NewTableWriter creates a new table writer +func NewTableWriter(w io.Writer, headers ...string) *TableWriter { + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = len(h) + } + return &TableWriter{ + w: w, + headers: headers, + widths: widths, + } +} + +// AddRow adds a row to the table +func (t *TableWriter) AddRow(cells ...string) { + // Pad or truncate to match header count + row := make([]string, len(t.headers)) + for i := range row { + if i < len(cells) { + row[i] = cells[i] + } + if len(row[i]) > t.widths[i] { + t.widths[i] = len(row[i]) + } + } + t.rows = append(t.rows, row) +} + +// Render outputs the table +func (t *TableWriter) Render() { + // Print headers + for i, h := range t.headers { + fmt.Fprintf(t.w, "%-*s", t.widths[i]+2, h) + } + fmt.Fprintln(t.w) + + // Print rows + for _, row := range t.rows { + for i, cell := range row { + fmt.Fprintf(t.w, "%-*s", t.widths[i]+2, cell) + } + fmt.Fprintln(t.w) + } +} + +// FormatTimeAgo formats a time as "X ago" string +func FormatTimeAgo(t time.Time) string { + if t.IsZero() { + return "N/A" + } + + d := time.Since(t) + + switch { + case d < time.Minute: + return fmt.Sprintf("%d seconds ago", int(d.Seconds())) + case d < time.Hour: + mins := int(d.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case d < 24*time.Hour: + hours := int(d.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + default: + days := int(d.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + } +} + +// TruncateID truncates an ID to 12 characters (like Docker) +func TruncateID(id string) string { + if len(id) > 12 { + return id[:12] + } + return id +} + +// TruncateString truncates a string to max length with ellipsis +func TruncateString(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return s[:max-3] + "..." +} + +// GenerateInstanceName generates a name from image reference +func GenerateInstanceName(image string) string { + // Extract image name without registry/tag + name := image + + // Remove registry prefix + if idx := strings.LastIndex(name, "/"); idx != -1 { + name = name[idx+1:] + } + + // Remove tag/digest + if idx := strings.Index(name, ":"); idx != -1 { + name = name[:idx] + } + if idx := strings.Index(name, "@"); idx != -1 { + name = name[:idx] + } + + // Add random suffix + suffix := randomSuffix(4) + return fmt.Sprintf("%s-%s", name, suffix) +} + +// randomSuffix generates a random alphanumeric suffix +func randomSuffix(n int) string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, n) + for i := range b { + // Simple pseudo-random using time + b[i] = chars[(time.Now().UnixNano()+int64(i))%int64(len(chars))] + } + return string(b) +} + + diff --git a/pkg/cmd/logs.go b/pkg/cmd/logs.go new file mode 100644 index 0000000..1cbd83d --- /dev/null +++ b/pkg/cmd/logs.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/onkernel/hypeman-go" + "github.com/onkernel/hypeman-go/option" + "github.com/urfave/cli/v3" +) + +var logsCmd = cli.Command{ + Name: "logs", + Usage: "Fetch the logs of an instance", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "follow", + Aliases: []string{"f"}, + Usage: "Follow log output", + }, + &cli.IntFlag{ + Name: "tail", + Usage: "Number of lines to show from the end of the logs", + Value: 100, + }, + }, + Action: handleLogs, + HideHelpCommand: true, +} + +func handleLogs(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance ID required\nUsage: hypeman logs [flags] ") + } + + instanceID := args[0] + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + params := hypeman.InstanceStreamLogsParams{} + if cmd.IsSet("follow") { + params.Follow = hypeman.Opt(cmd.Bool("follow")) + } + if cmd.IsSet("tail") { + params.Tail = hypeman.Opt(int64(cmd.Int("tail"))) + } + + stream := client.Instances.StreamLogsStreaming( + ctx, + instanceID, + params, + option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), + ) + defer stream.Close() + + for stream.Next() { + fmt.Printf("%s\n", stream.Current()) + } + + return stream.Err() +} + + diff --git a/pkg/cmd/ps.go b/pkg/cmd/ps.go new file mode 100644 index 0000000..ed3596c --- /dev/null +++ b/pkg/cmd/ps.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/onkernel/hypeman-go" + "github.com/onkernel/hypeman-go/option" + "github.com/urfave/cli/v3" +) + +var psCmd = cli.Command{ + Name: "ps", + Usage: "List instances", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "all", + Aliases: []string{"a"}, + Usage: "Show all instances (default: running only)", + }, + &cli.BoolFlag{ + Name: "quiet", + Aliases: []string{"q"}, + Usage: "Only display instance IDs", + }, + }, + Action: handlePs, + HideHelpCommand: true, +} + +func handlePs(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + instances, err := client.Instances.List( + ctx, + option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), + ) + if err != nil { + return err + } + + showAll := cmd.Bool("all") + quietMode := cmd.Bool("quiet") + + // Filter instances + var filtered []hypeman.Instance + for _, inst := range *instances { + if showAll || inst.State == "Running" { + filtered = append(filtered, inst) + } + } + + // Quiet mode - just IDs + if quietMode { + for _, inst := range filtered { + fmt.Println(inst.ID) + } + return nil + } + + // Table output + if len(filtered) == 0 { + if !showAll { + fmt.Fprintln(os.Stderr, "No running instances. Use -a to show all.") + } + return nil + } + + table := NewTableWriter(os.Stdout, "INSTANCE ID", "NAME", "IMAGE", "STATE", "CREATED") + for _, inst := range filtered { + table.AddRow( + TruncateID(inst.ID), + TruncateString(inst.Name, 20), + TruncateString(inst.Image, 25), + string(inst.State), + FormatTimeAgo(inst.CreatedAt), + ) + } + table.Render() + + return nil +} + diff --git a/pkg/cmd/pull.go b/pkg/cmd/pull.go new file mode 100644 index 0000000..2c0d0c9 --- /dev/null +++ b/pkg/cmd/pull.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/onkernel/hypeman-go" + "github.com/onkernel/hypeman-go/option" + "github.com/urfave/cli/v3" +) + +var pullCmd = cli.Command{ + Name: "pull", + Usage: "Pull an image from a registry", + ArgsUsage: "", + Action: handlePull, + HideHelpCommand: true, +} + +func handlePull(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("image reference required\nUsage: hypeman pull ") + } + + image := args[0] + + fmt.Fprintf(os.Stderr, "Pulling %s...\n", image) + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + params := hypeman.ImageNewParams{ + Name: image, + } + + result, err := client.Images.New( + ctx, + params, + option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), + ) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Status: %s\n", result.Status) + if result.Digest != "" { + fmt.Fprintf(os.Stderr, "Digest: %s\n", result.Digest) + } + fmt.Fprintf(os.Stderr, "Image: %s\n", result.Name) + + return nil +} + diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go new file mode 100644 index 0000000..f5f8222 --- /dev/null +++ b/pkg/cmd/run.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/onkernel/hypeman-go" + "github.com/onkernel/hypeman-go/option" + "github.com/urfave/cli/v3" +) + +var runCmd = cli.Command{ + Name: "run", + Usage: "Create and start a new instance from an image", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "Instance name (auto-generated if not provided)", + }, + &cli.StringSliceFlag{ + Name: "env", + Aliases: []string{"e"}, + Usage: "Set environment variable (KEY=VALUE, can be repeated)", + }, + &cli.StringFlag{ + Name: "memory", + Usage: `Base memory size (e.g., "1GB", "512MB")`, + Value: "1GB", + }, + &cli.IntFlag{ + Name: "cpus", + Usage: "Number of virtual CPUs", + Value: 2, + }, + &cli.StringFlag{ + Name: "overlay-size", + Usage: `Writable overlay disk size (e.g., "10GB")`, + Value: "10GB", + }, + &cli.StringFlag{ + Name: "hotplug-size", + Usage: `Additional memory for hotplug (e.g., "3GB")`, + Value: "3GB", + }, + &cli.BoolFlag{ + Name: "network", + Usage: "Enable network (default: true)", + Value: true, + }, + }, + Action: handleRun, + HideHelpCommand: true, +} + +func handleRun(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("image reference required\nUsage: hypeman run [flags] ") + } + + image := args[0] + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + // Check if image exists, pull if not + _, err := client.Images.Get(ctx, image) + if err != nil { + // Image not found, try to pull it + var apiErr *hypeman.Error + if ok := isNotFoundError(err, &apiErr); ok { + fmt.Fprintf(os.Stderr, "Image not found locally. Pulling %s...\n", image) + _, err = client.Images.New(ctx, hypeman.ImageNewParams{ + Name: image, + }) + if err != nil { + return fmt.Errorf("failed to pull image: %w", err) + } + fmt.Fprintf(os.Stderr, "Pull complete.\n") + } else { + return fmt.Errorf("failed to check image: %w", err) + } + } + + // Generate name if not provided + name := cmd.String("name") + if name == "" { + name = GenerateInstanceName(image) + } + + // Parse environment variables + env := make(map[string]string) + for _, e := range cmd.StringSlice("env") { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + env[parts[0]] = parts[1] + } else { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed env var: %s\n", e) + } + } + + // Build instance params + // Note: SDK uses memory in MB, but we accept human-readable format + // For simplicity, we pass memory as-is and let the server handle conversion + params := hypeman.InstanceNewParams{ + Image: image, + Name: name, + Vcpus: hypeman.Opt(int64(cmd.Int("cpus"))), + } + if len(env) > 0 { + params.Env = env + } + + fmt.Fprintf(os.Stderr, "Creating instance %s...\n", name) + + result, err := client.Instances.New( + ctx, + params, + option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), + ) + if err != nil { + return err + } + + // Output instance ID (useful for scripting) + fmt.Println(result.ID) + + return nil +} + +// isNotFoundError checks if err is a 404 not found error +func isNotFoundError(err error, target **hypeman.Error) bool { + if apiErr, ok := err.(*hypeman.Error); ok { + *target = apiErr + return apiErr.Response != nil && apiErr.Response.StatusCode == 404 + } + return false +} + diff --git a/pkg/cmd/util.go b/pkg/cmd/util.go index 59a6481..8770990 100644 --- a/pkg/cmd/util.go +++ b/pkg/cmd/util.go @@ -36,10 +36,12 @@ func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { option.WithHeader("X-Stainless-CLI-Command", cmd.FullName()), } - // Override base URL if the --base-url flag is provided - if baseURL := cmd.String("base-url"); baseURL != "" { - opts = append(opts, option.WithBaseURL(baseURL)) + // Set base URL (default to localhost for development) + baseURL := cmd.String("base-url") + if baseURL == "" { + baseURL = "http://localhost:8080" } + opts = append(opts, option.WithBaseURL(baseURL)) return opts } From 3a85c7f3cf55c3d6bc0c651f33bfad40823c747f Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Wed, 26 Nov 2025 12:16:14 -0500 Subject: [PATCH 4/7] Add rm --- pkg/cmd/cmd.go | 1 + pkg/cmd/rm.go | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 pkg/cmd/rm.go diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index a115c0b..b69373e 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -72,6 +72,7 @@ func init() { &runCmd, &psCmd, &logsCmd, + &rmCmd, { Name: "health", Category: "API RESOURCE", diff --git a/pkg/cmd/rm.go b/pkg/cmd/rm.go new file mode 100644 index 0000000..a23b289 --- /dev/null +++ b/pkg/cmd/rm.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/onkernel/hypeman-go" + "github.com/onkernel/hypeman-go/option" + "github.com/urfave/cli/v3" +) + +var rmCmd = cli.Command{ + Name: "rm", + Usage: "Remove one or more instances", + ArgsUsage: " [instance...]", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "Force removal of running instances", + }, + }, + Action: handleRm, + HideHelpCommand: true, +} + +func handleRm(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance ID required\nUsage: hypeman rm [flags] [instance...]") + } + + force := cmd.Bool("force") + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var lastErr error + for _, instanceID := range args { + // Check instance state if not forcing + if !force { + inst, err := client.Instances.Get( + ctx, + instanceID, + option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), + ) + if err != nil { + fmt.Printf("Error: failed to get instance %s: %v\n", instanceID, err) + lastErr = err + continue + } + + if inst.State == "Running" { + fmt.Printf("Error: cannot remove running instance %s. Stop it first or use --force\n", instanceID) + lastErr = fmt.Errorf("instance is running") + continue + } + } + + // Delete the instance + err := client.Instances.Delete( + ctx, + instanceID, + option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), + ) + if err != nil { + fmt.Printf("Error: failed to remove instance %s: %v\n", instanceID, err) + lastErr = err + continue + } + + fmt.Println(instanceID) + } + + return lastErr +} + From 29dde0efab93d630a855ce367dfd1af677671d0b Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Wed, 26 Nov 2025 12:20:44 -0500 Subject: [PATCH 5/7] Partial ID match and name match --- pkg/cmd/exec.go | 9 ++++++++- pkg/cmd/format.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ pkg/cmd/logs.go | 8 ++++++-- pkg/cmd/rm.go | 12 ++++++++++-- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/exec.go b/pkg/cmd/exec.go index 9a073cf..b0defbb 100644 --- a/pkg/cmd/exec.go +++ b/pkg/cmd/exec.go @@ -14,6 +14,7 @@ import ( "syscall" "github.com/gorilla/websocket" + "github.com/onkernel/hypeman-go" "github.com/urfave/cli/v3" "golang.org/x/term" ) @@ -75,7 +76,13 @@ func handleExec(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("instance ID required\nUsage: hypeman exec [flags] [-- command...]") } - instanceID := args[0] + // Resolve instance by ID, partial ID, or name + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + var command []string // Parse command after -- separator or remaining args diff --git a/pkg/cmd/format.go b/pkg/cmd/format.go index 44e8e80..8e9b1df 100644 --- a/pkg/cmd/format.go +++ b/pkg/cmd/format.go @@ -1,10 +1,13 @@ package cmd import ( + "context" "fmt" "io" "strings" "time" + + "github.com/onkernel/hypeman-go" ) // TableWriter provides simple table formatting for CLI output @@ -145,4 +148,48 @@ func randomSuffix(n int) string { return string(b) } +// ResolveInstance resolves an instance identifier to a full instance ID. +// It supports: +// - Full instance ID (exact match) +// - Partial instance ID (prefix match) +// - Instance name (exact match) +// Returns an error if the identifier is ambiguous or not found. +func ResolveInstance(ctx context.Context, client *hypeman.Client, identifier string) (string, error) { + // List all instances + instances, err := client.Instances.List(ctx) + if err != nil { + return "", fmt.Errorf("failed to list instances: %w", err) + } + + var matches []hypeman.Instance + + for _, inst := range *instances { + // Exact ID match - return immediately + if inst.ID == identifier { + return inst.ID, nil + } + // Exact name match - return immediately + if inst.Name == identifier { + return inst.ID, nil + } + // Partial ID match (prefix) + if strings.HasPrefix(inst.ID, identifier) { + matches = append(matches, inst) + } + } + + switch len(matches) { + case 0: + return "", fmt.Errorf("no instance found matching %q", identifier) + case 1: + return matches[0].ID, nil + default: + // Ambiguous - show matching IDs + ids := make([]string, len(matches)) + for i, m := range matches { + ids[i] = TruncateID(m.ID) + } + return "", fmt.Errorf("ambiguous instance identifier %q matches: %s", identifier, strings.Join(ids, ", ")) + } +} diff --git a/pkg/cmd/logs.go b/pkg/cmd/logs.go index 1cbd83d..daa1e2b 100644 --- a/pkg/cmd/logs.go +++ b/pkg/cmd/logs.go @@ -35,10 +35,14 @@ func handleLogs(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("instance ID required\nUsage: hypeman logs [flags] ") } - instanceID := args[0] - client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + // Resolve instance by ID, partial ID, or name + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + params := hypeman.InstanceStreamLogsParams{} if cmd.IsSet("follow") { params.Follow = hypeman.Opt(cmd.Bool("follow")) diff --git a/pkg/cmd/rm.go b/pkg/cmd/rm.go index a23b289..977b210 100644 --- a/pkg/cmd/rm.go +++ b/pkg/cmd/rm.go @@ -34,7 +34,15 @@ func handleRm(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) var lastErr error - for _, instanceID := range args { + for _, identifier := range args { + // Resolve instance by ID, partial ID, or name + instanceID, err := ResolveInstance(ctx, &client, identifier) + if err != nil { + fmt.Printf("Error: %v\n", err) + lastErr = err + continue + } + // Check instance state if not forcing if !force { inst, err := client.Instances.Get( @@ -56,7 +64,7 @@ func handleRm(ctx context.Context, cmd *cli.Command) error { } // Delete the instance - err := client.Instances.Delete( + err = client.Instances.Delete( ctx, instanceID, option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), From 27314c0b0dfec742b31dbc33de2e073ad92f0370 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Wed, 26 Nov 2025 12:23:24 -0500 Subject: [PATCH 6/7] Add support for rm --all --- pkg/cmd/rm.go | 56 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/rm.go b/pkg/cmd/rm.go index 977b210..2d2139a 100644 --- a/pkg/cmd/rm.go +++ b/pkg/cmd/rm.go @@ -12,13 +12,17 @@ import ( var rmCmd = cli.Command{ Name: "rm", Usage: "Remove one or more instances", - ArgsUsage: " [instance...]", + ArgsUsage: "[instance...]", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "force", Aliases: []string{"f"}, Usage: "Force removal of running instances", }, + &cli.BoolFlag{ + Name: "all", + Usage: "Remove all instances (stopped only, unless --force)", + }, }, Action: handleRm, HideHelpCommand: true, @@ -26,21 +30,47 @@ var rmCmd = cli.Command{ func handleRm(ctx context.Context, cmd *cli.Command) error { args := cmd.Args().Slice() - if len(args) < 1 { - return fmt.Errorf("instance ID required\nUsage: hypeman rm [flags] [instance...]") + force := cmd.Bool("force") + all := cmd.Bool("all") + + if !all && len(args) < 1 { + return fmt.Errorf("instance ID required\nUsage: hypeman rm [flags] [instance...]\n hypeman rm --all [--force]") } - force := cmd.Bool("force") client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) - var lastErr error - for _, identifier := range args { - // Resolve instance by ID, partial ID, or name - instanceID, err := ResolveInstance(ctx, &client, identifier) + // If --all, get all instance IDs + var identifiers []string + if all { + instances, err := client.Instances.List(ctx) if err != nil { - fmt.Printf("Error: %v\n", err) - lastErr = err - continue + return fmt.Errorf("failed to list instances: %w", err) + } + for _, inst := range *instances { + identifiers = append(identifiers, inst.ID) + } + if len(identifiers) == 0 { + fmt.Println("No instances to remove") + return nil + } + } else { + identifiers = args + } + + var lastErr error + for _, identifier := range identifiers { + // Resolve instance by ID, partial ID, or name (skip if --all since we have full IDs) + var instanceID string + var err error + if all { + instanceID = identifier + } else { + instanceID, err = ResolveInstance(ctx, &client, identifier) + if err != nil { + fmt.Printf("Error: %v\n", err) + lastErr = err + continue + } } // Check instance state if not forcing @@ -57,6 +87,10 @@ func handleRm(ctx context.Context, cmd *cli.Command) error { } if inst.State == "Running" { + if all { + // Silently skip running instances when using --all without --force + continue + } fmt.Printf("Error: cannot remove running instance %s. Stop it first or use --force\n", instanceID) lastErr = fmt.Errorf("instance is running") continue From 9da0b1655ba0ba13e0a2aac6c88fc7371e0c34e5 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Wed, 26 Nov 2025 13:51:50 -0500 Subject: [PATCH 7/7] Fix logs --- pkg/cmd/logs.go | 62 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/pkg/cmd/logs.go b/pkg/cmd/logs.go index daa1e2b..ebb9635 100644 --- a/pkg/cmd/logs.go +++ b/pkg/cmd/logs.go @@ -3,9 +3,12 @@ package cmd import ( "context" "fmt" + "io" + "net/http" + "net/url" + "os" "github.com/onkernel/hypeman-go" - "github.com/onkernel/hypeman-go/option" "github.com/urfave/cli/v3" ) @@ -43,27 +46,54 @@ func handleLogs(ctx context.Context, cmd *cli.Command) error { return err } - params := hypeman.InstanceStreamLogsParams{} - if cmd.IsSet("follow") { - params.Follow = hypeman.Opt(cmd.Bool("follow")) + // Build URL for logs endpoint + baseURL := cmd.Root().String("base-url") + if baseURL == "" { + baseURL = os.Getenv("HYPEMAN_BASE_URL") } - if cmd.IsSet("tail") { - params.Tail = hypeman.Opt(int64(cmd.Int("tail"))) + if baseURL == "" { + baseURL = "http://localhost:8080" } - stream := client.Instances.StreamLogsStreaming( - ctx, - instanceID, - params, - option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), - ) - defer stream.Close() + u, err := url.Parse(baseURL) + if err != nil { + return fmt.Errorf("invalid base URL: %w", err) + } + u.Path = fmt.Sprintf("/instances/%s/logs", instanceID) + + // Add query parameters + q := u.Query() + q.Set("tail", fmt.Sprintf("%d", cmd.Int("tail"))) + if cmd.Bool("follow") { + q.Set("follow", "true") + } + u.RawQuery = q.Encode() + + // Make HTTP request + req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + apiKey := os.Getenv("HYPEMAN_API_KEY") + if apiKey != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch logs: %w", err) + } + defer resp.Body.Close() - for stream.Next() { - fmt.Printf("%s\n", stream.Current()) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to fetch logs (HTTP %d): %s", resp.StatusCode, string(body)) } - return stream.Err() + // Stream the response to stdout + _, err = io.Copy(os.Stdout, resp.Body) + return err }