Skip to content

Commit 0c204cc

Browse files
committed
feat(tui): add instance actions in detail view, drop auto-SSH on deploy
- TUI detail view now supports start, stop, force stop, restart, force restart, and rename (inline text input) without leaving the TUI - Removed post-deploy "Connect now?" prompt — deploy just prints the success card and returns - SSH command shows "OS is still being installed" message when connection fails within 10 minutes of instance creation instead of falling through to sshpass auth errors
1 parent a778ecd commit 0c204cc

4 files changed

Lines changed: 233 additions & 53 deletions

File tree

cmd/instances/deploy.go

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7-
"os/exec"
87
"strings"
98
"time"
109

@@ -207,7 +206,6 @@ func runDeploy(cmd *cobra.Command, args []string) error {
207206
return printJSONResult(instance, selectedRegion, selectedPlan)
208207
}
209208
displayDeployResult(instance, selectedRegion, selectedPlan)
210-
promptSSHConnect(hostname)
211209
return nil
212210
}
213211

@@ -536,30 +534,6 @@ SSH: ssh %s@%s`,
536534
fmt.Println("\n" + cardStyle.Render(cardContent) + "\n")
537535
}
538536

539-
func promptSSHConnect(hostname string) {
540-
connectNow := true
541-
err := huh.NewConfirm().
542-
Title("Connect now?").
543-
Value(&connectNow).
544-
Run()
545-
if err != nil {
546-
return
547-
}
548-
if connectNow {
549-
// Re-exec as subprocess so os.Exit in RunSSH doesn't bypass our deferred cleanup
550-
binary, err := os.Executable()
551-
if err != nil {
552-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
553-
return
554-
}
555-
cmd := exec.Command(binary, "ssh", hostname)
556-
cmd.Stdin = os.Stdin
557-
cmd.Stdout = os.Stdout
558-
cmd.Stderr = os.Stderr
559-
cmd.Run()
560-
}
561-
}
562-
563537
// --- Search helpers ---
564538

565539
func findTemplate(templates []api.Template, name string) (*api.Template, error) {

cmd/instances/list.go

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package instances
22

33
import (
44
"fmt"
5+
"time"
56

67
tea "github.com/charmbracelet/bubbletea"
78
"github.com/hostodo/hostodo-cli/pkg/api"
89
"github.com/hostodo/hostodo-cli/pkg/auth"
910
"github.com/hostodo/hostodo-cli/pkg/config"
11+
"github.com/hostodo/hostodo-cli/pkg/deploy"
12+
"github.com/hostodo/hostodo-cli/pkg/resolver"
1013
"github.com/hostodo/hostodo-cli/pkg/ui"
1114
"github.com/spf13/cobra"
1215
)
@@ -84,21 +87,18 @@ func runList(cmd *cobra.Command, args []string) {
8487

8588
// Display based on output format
8689
if instanceListJSON {
87-
// JSON output
8890
output, err := ui.FormatInstancesJSON(instancesResp.Results)
8991
if err != nil {
9092
exitWithError("Failed to format JSON: %v", err)
9193
}
9294
fmt.Println(output)
9395
} else if instanceListSimple {
94-
// Simple table output
95-
fmt.Println() // Add spacing
96+
fmt.Println()
9697
output := ui.FormatInstancesSimpleTable(instancesResp.Results)
9798
fmt.Println(output)
9899
fmt.Printf("\nTotal: %d instances\n", instancesResp.Count)
99100
} else if instanceListDetails {
100-
// Detailed output
101-
fmt.Println() // Add spacing
101+
fmt.Println()
102102
output := ui.FormatInstancesDetailedTable(instancesResp.Results)
103103
fmt.Println(output)
104104
fmt.Printf("\nTotal: %d instances\n", instancesResp.Count)
@@ -109,9 +109,115 @@ func runList(cmd *cobra.Command, args []string) {
109109
if err != nil {
110110
exitWithError("Failed to run interactive table: %v", err)
111111
}
112-
// Check if user requested SSH from detail view
113-
if tm, ok := finalModel.(ui.TableModel); ok && tm.SSHHostname != "" {
112+
tm, ok := finalModel.(ui.TableModel)
113+
if !ok {
114+
return
115+
}
116+
// Handle actions requested from within the TUI
117+
if tm.SSHHostname != "" {
114118
RunSSH(SSHCmd, []string{tm.SSHHostname})
119+
return
120+
}
121+
if tm.APIAction != "" && tm.ActionInstance != nil {
122+
runTUIAction(client, tm)
123+
}
124+
}
125+
}
126+
127+
// runTUIAction executes an instance action that was triggered from the TUI detail view.
128+
func runTUIAction(client *api.Client, tm ui.TableModel) {
129+
instance := tm.ActionInstance
130+
fmt.Println()
131+
132+
switch tm.APIAction {
133+
case "start":
134+
fmt.Printf("Starting instance %s (%s)...\n", instance.Hostname, instance.MainIP)
135+
if err := client.StartInstance(instance.InstanceID); err != nil {
136+
exitWithError("Failed to start instance: %v", err)
137+
}
138+
fmt.Println("✓ Start command sent")
139+
fmt.Print("\nWaiting for instance to start")
140+
pollPower(client, instance.InstanceID, "running", 30)
141+
142+
case "stop":
143+
fmt.Printf("Stopping instance %s (%s)...\n", instance.Hostname, instance.MainIP)
144+
if err := client.StopInstance(instance.InstanceID, false); err != nil {
145+
exitWithError("Failed to stop instance: %v", err)
146+
}
147+
fmt.Println("✓ Stop command sent")
148+
fmt.Print("\nWaiting for instance to stop")
149+
pollPower(client, instance.InstanceID, "stopped", 30)
150+
151+
case "force-stop":
152+
fmt.Printf("Force stopping instance %s (%s)...\n", instance.Hostname, instance.MainIP)
153+
if err := client.StopInstance(instance.InstanceID, true); err != nil {
154+
exitWithError("Failed to force stop instance: %v", err)
155+
}
156+
fmt.Println("✓ Force stop command sent")
157+
158+
case "restart":
159+
fmt.Printf("Restarting instance %s (%s)...\n", instance.Hostname, instance.MainIP)
160+
if err := client.RebootInstance(instance.InstanceID, false); err != nil {
161+
exitWithError("Failed to restart instance: %v", err)
162+
}
163+
fmt.Println("✓ Restart command sent")
164+
fmt.Print("\nWaiting for instance to restart")
165+
pollRestart(client, instance.InstanceID, 60)
166+
167+
case "force-restart":
168+
fmt.Printf("Force restarting instance %s (%s)...\n", instance.Hostname, instance.MainIP)
169+
if err := client.RebootInstance(instance.InstanceID, true); err != nil {
170+
exitWithError("Failed to force restart instance: %v", err)
171+
}
172+
fmt.Println("✓ Force restart command sent")
173+
174+
case "rename":
175+
newHostname := tm.RenameTarget
176+
if err := deploy.Validate(newHostname); err != nil {
177+
exitWithError("Invalid hostname %q: %v", newHostname, err)
178+
}
179+
fmt.Printf("Renaming instance %s to %s...\n", instance.Hostname, newHostname)
180+
if err := client.RenameInstance(instance.InstanceID, newHostname); err != nil {
181+
exitWithError("Failed to rename instance: %v", err)
182+
}
183+
resolver.InvalidateCache()
184+
fmt.Printf("✓ Instance renamed to %s\n", newHostname)
185+
}
186+
}
187+
188+
// pollPower polls until the instance reaches targetStatus or timeout.
189+
func pollPower(client *api.Client, instanceID, targetStatus string, seconds int) {
190+
for i := 0; i < seconds; i++ {
191+
fmt.Print(".")
192+
time.Sleep(1 * time.Second)
193+
status, err := client.GetInstancePowerStatus(instanceID)
194+
if err == nil && status == targetStatus {
195+
fmt.Println()
196+
fmt.Printf("✓ Instance is now %s\n", targetStatus)
197+
return
198+
}
199+
}
200+
fmt.Println()
201+
fmt.Println("⚠ Operation in progress (this may take a few moments)")
202+
}
203+
204+
// pollRestart polls for running state after a reboot, allowing for a stop transition.
205+
func pollRestart(client *api.Client, instanceID string, seconds int) {
206+
sawStopped := false
207+
for i := 0; i < seconds; i++ {
208+
fmt.Print(".")
209+
time.Sleep(1 * time.Second)
210+
status, err := client.GetInstancePowerStatus(instanceID)
211+
if err == nil {
212+
if status == "stopped" {
213+
sawStopped = true
214+
} else if status == "running" && (sawStopped || i >= 5) {
215+
fmt.Println()
216+
fmt.Println("✓ Instance has restarted and is now running")
217+
return
218+
}
115219
}
116220
}
221+
fmt.Println()
222+
fmt.Println("⚠ Instance is restarting (this may take a few moments)")
117223
}

cmd/instances/ssh.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"os/exec"
77
"runtime"
8+
"time"
89

910
"github.com/hostodo/hostodo-cli/pkg/api"
1011
"github.com/hostodo/hostodo-cli/pkg/auth"
@@ -137,6 +138,11 @@ func RunSSH(cmd *cobra.Command, args []string) {
137138
// Only retry with sshpass on SSH connection/auth failure (exit code 255).
138139
// Other non-zero codes (1-254) are the remote command's exit status from
139140
// a session that connected successfully — don't retry those.
141+
if exitCode == 255 && isRecentlyCreated(instance, 10*time.Minute) {
142+
fmt.Fprintf(os.Stderr, "\nOS is still being installed — hang tight! Try again in a minute or two.\n")
143+
os.Exit(exitCode)
144+
}
145+
140146
if exitCode == 255 && instance.DefaultPassword != "" {
141147
sshpassBinary, err := exec.LookPath("sshpass")
142148
if err != nil {
@@ -197,6 +203,19 @@ func buildSSHArgs(target string, hasPasswordFallback bool, extraArgs []string) [
197203
return args
198204
}
199205

206+
// isRecentlyCreated returns true if the instance was created within the given window.
207+
// The API serializes DateTimeField as ISO 8601 with microseconds and UTC Z suffix.
208+
func isRecentlyCreated(instance *api.Instance, window time.Duration) bool {
209+
if instance.CreatedAt == "" {
210+
return false
211+
}
212+
t, err := time.Parse("2006-01-02T15:04:05.999999Z", instance.CreatedAt)
213+
if err != nil {
214+
return false
215+
}
216+
return time.Since(t) < window
217+
}
218+
200219
func runSSHCommand(binary string, args []string) int {
201220
cmd := exec.Command(binary, args...)
202221
cmd.Stdin = os.Stdin

0 commit comments

Comments
 (0)