Skip to content
Merged
60 changes: 36 additions & 24 deletions internal/tiger/cmd/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,16 +403,17 @@ Note: You can specify both CPU and memory together, or specify only one (the oth
}

// Handle wait behavior
var result error
var serviceErr error
if createNoWait {
fmt.Fprintf(statusOutput, "⏳ Service is being created. Use 'tiger service list' to check status.\n")
} else {
// Wait for service to be ready
fmt.Fprintf(statusOutput, "⏳ Waiting for service to be ready (wait timeout: %v)...\n", createWaitTimeout)
service.Status, result = waitForServiceReady(client, projectID, serviceID, createWaitTimeout, service.Status, statusOutput)
if result != nil {
fmt.Fprintf(statusOutput, "❌ %v\n", result)
service.Status, serviceErr = waitForServiceReady(client, projectID, serviceID, createWaitTimeout, service.Status, statusOutput)
if serviceErr != nil {
fmt.Fprintf(statusOutput, "❌ Error: %s\n", serviceErr)
} else {
fmt.Fprintf(statusOutput, "🎉 Service is ready and running!\n")
printConnectMessage(statusOutput, passwordSaved, createNoSetDefault, serviceID)
}
}
Expand All @@ -421,7 +422,9 @@ Note: You can specify both CPU and memory together, or specify only one (the oth
fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to output service details: %v\n", err)
}

return result
// Return error for sake of exit code, but silence it since it was already output above
cmd.SilenceErrors = true
return serviceErr
default:
return exitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX)
}
Expand Down Expand Up @@ -811,23 +814,27 @@ func waitForServiceReady(client *api.ClientWithResponses, projectID, serviceID s
ctx, cancel := context.WithTimeout(context.Background(), waitTimeout)
defer cancel()

ticker := time.NewTicker(10 * time.Second)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

// Start the spinner
spinner := NewSpinner(output, "Service status: %s", util.DerefStr(initialStatus))
defer spinner.Stop()

lastStatus := initialStatus
for {
select {
case <-ctx.Done():
return lastStatus, exitWithCode(ExitTimeout, fmt.Errorf("wait timeout reached after %v - service may still be provisioning", waitTimeout))
return lastStatus, exitWithCode(ExitTimeout, fmt.Errorf("wait timeout reached after %v - service may still be provisioning", waitTimeout))
case <-ticker.C:
resp, err := client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, serviceID)
if err != nil {
fmt.Fprintf(output, "⚠️ Error checking service status: %v\n", err)
spinner.Update("Error checking service status: %v", err)
continue
}

if resp.StatusCode() != 200 || resp.JSON200 == nil {
fmt.Fprintf(output, "⚠️ Service not found or error checking status\n")
spinner.Update("Service not found or error checking status")
continue
}

Expand All @@ -837,12 +844,11 @@ func waitForServiceReady(client *api.ClientWithResponses, projectID, serviceID s

switch status {
case "READY":
fmt.Fprintf(output, "🎉 Service is ready and running!\n")
return service.Status, nil
case "FAILED", "ERROR":
return service.Status, fmt.Errorf("service creation failed with status: %s", status)
default:
fmt.Fprintf(output, "⏳ Service status: %s...\n", status)
spinner.Update("Service status: %s", status)
}
}
}
Expand Down Expand Up @@ -993,7 +999,13 @@ Examples:
}

// Wait for deletion to complete
return waitForServiceDeletion(client, cfg.ProjectID, serviceID, deleteWaitTimeout, cmd)
if err := waitForServiceDeletion(client, cfg.ProjectID, serviceID, deleteWaitTimeout, cmd); err != nil {
// Return error for sake of exit code, but log ourselves for sake of icon
fmt.Fprintf(statusOutput, "❌ Error: %s\n", err)
cmd.SilenceErrors = true
return err
}
return nil
},
}

Expand All @@ -1009,17 +1021,18 @@ func waitForServiceDeletion(client *api.ClientWithResponses, projectID string, s
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

ticker := time.NewTicker(10 * time.Second)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

statusOutput := cmd.ErrOrStderr()

fmt.Fprintf(statusOutput, "⏳ Waiting for service '%s' to be deleted", serviceID)
// Start the spinner
spinner := NewSpinner(statusOutput, "Waiting for service '%s' to be deleted", serviceID)
defer spinner.Stop()

for {
select {
case <-ctx.Done():
fmt.Fprintln(statusOutput, "") // New line after dots
return exitWithCode(ExitTimeout, fmt.Errorf("timeout waiting for service '%s' to be deleted after %v", serviceID, timeout))
case <-ticker.C:
// Check if service still exists
Expand All @@ -1029,25 +1042,22 @@ func waitForServiceDeletion(client *api.ClientWithResponses, projectID string, s
api.ServiceId(serviceID),
)
if err != nil {
fmt.Fprintln(statusOutput, "") // New line after dots
return fmt.Errorf("failed to check service status: %w", err)
}

if resp.StatusCode() == 404 {
// Service is deleted
fmt.Fprintln(statusOutput, "") // New line after dots
spinner.Stop()
fmt.Fprintf(statusOutput, "✅ Service '%s' has been successfully deleted.\n", serviceID)
return nil
}

if resp.StatusCode() == 200 {
// Service still exists, continue waiting
fmt.Fprint(statusOutput, ".")
continue
}

// Other error
fmt.Fprintln(statusOutput, "") // New line after dots
return fmt.Errorf("unexpected response while checking service status: %d", resp.StatusCode())
}
}
Expand Down Expand Up @@ -1255,15 +1265,15 @@ Examples:
}

// Handle wait behavior
var result error
var serviceErr error
if forkNoWait {
fmt.Fprintf(statusOutput, "⏳ Service is being forked. Use 'tiger service list' to check status.\n")
} else {
// Wait for service to be ready
fmt.Fprintf(statusOutput, "⏳ Waiting for fork to complete (timeout: %v)...\n", forkWaitTimeout)
forkedService.Status, result = waitForServiceReady(client, projectID, forkedServiceID, forkWaitTimeout, forkedService.Status, statusOutput)
if result != nil {
fmt.Fprintf(statusOutput, "❌ %v\n", result)
forkedService.Status, serviceErr = waitForServiceReady(client, projectID, forkedServiceID, forkWaitTimeout, forkedService.Status, statusOutput)
if serviceErr != nil {
fmt.Fprintf(statusOutput, "❌ Error: %s\n", serviceErr)
} else {
fmt.Fprintf(statusOutput, "🎉 Service fork completed successfully!\n")
printConnectMessage(statusOutput, passwordSaved, forkNoSetDefault, forkedServiceID)
Expand All @@ -1274,7 +1284,9 @@ Examples:
fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to output service details: %v\n", err)
}

return result
// Return error for sake of exit code, but silence it since it was already output above
cmd.SilenceErrors = true
return serviceErr
},
}

Expand Down
126 changes: 126 additions & 0 deletions internal/tiger/cmd/spinner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package cmd

import (
"fmt"
"io"
"os"
"time"

tea "github.com/charmbracelet/bubbletea"
"golang.org/x/term"
)

// spinnerFrames defines the animation frames for the spinner
var spinnerFrames = []string{"⢎ ", "⠎⠁", "⠊⠑", "⠈⠱", " ⡱", "⢀⡰", "⢄⡠", "⢆⡀"}

type Spinner struct {
// Populated when output is a TTY
program *tea.Program

// Populated when output is not a TTY
output io.Writer
model *spinnerModel
}

func NewSpinner(output io.Writer, message string, args ...any) *Spinner {
model := spinnerModel{
message: fmt.Sprintf(message, args...),
}

// If output is not a TTY, print each message on a new line
if !isTerminal(output) {
s := &Spinner{
output: output,
model: &model,
}
s.println()
return s
}

// If output is a TTY, use bubbletea to dynamically update the message
program := tea.NewProgram(
model,
tea.WithOutput(output),
)

// Start the program in a goroutine
go func() {
if _, err := program.Run(); err != nil {
fmt.Fprintf(output, "Error displaying output: %s\n", err)
}
}()

return &Spinner{
program: program,
}
}

func (s *Spinner) Update(message string, args ...any) {
message = fmt.Sprintf(message, args...)
if s.program != nil {
s.program.Send(updateMsg(message))
} else if message != s.model.message {
s.model.message = message
s.model.incFrame()
s.println()
}
}

func (s *Spinner) Stop() {
if s.program == nil {
return
}

s.program.Quit()
s.program.Wait()
}

func (s *Spinner) println() {
fmt.Fprintln(s.output, s.model.View())
}

func isTerminal(w io.Writer) bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote a similar function for the version check stuff. Should consolidate these.

Reviewing on mobile, too hard to provide a link, sorry.

Copy link
Member Author

@nathanjcochran nathanjcochran Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call! Done here: ac42720.

I decided to rely on the official golang.org/x/term package rather than the github.com/mattn/go-isatty package you were using. I think the functionality should be essentially the same, but I trust the official package slightly more.

if f, ok := w.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}

// Message types for the bubbletea model
type tickMsg struct{}
type updateMsg string

// spinnerModel is the bubbletea model for the spinner
type spinnerModel struct {
message string
frame int
}

func (m spinnerModel) Init() tea.Cmd {
return m.tick()
}

func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tickMsg:
m.incFrame()
return m, m.tick()
case updateMsg:
m.message = string(msg)
}
return m, nil
}

func (m spinnerModel) View() string {
return fmt.Sprintf("%s %s", spinnerFrames[m.frame], m.message)
}

func (m *spinnerModel) incFrame() {
m.frame = (m.frame + 1) % len(spinnerFrames)
}

func (m *spinnerModel) tick() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg {
return tickMsg{}
})
}
2 changes: 1 addition & 1 deletion internal/tiger/mcp/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ func (s *Server) waitForServiceReady(apiClient *api.ClientWithResponses, project
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

ticker := time.NewTicker(10 * time.Second)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

lastStatus := initialStatus
Expand Down