Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ module github.com/timescale/tiger-cli
go 1.25.1

require (
github.com/Masterminds/semver/v3 v3.4.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/cli/safeexec v1.0.1
github.com/fatih/color v1.18.0
github.com/google/jsonschema-go v0.2.3
github.com/jackc/pgx/v5 v5.7.5
github.com/modelcontextprotocol/go-sdk v0.5.0
Expand All @@ -28,7 +31,6 @@ require (
al.essio.dev/pkg/shellescape v1.6.0 // indirect
github.com/1password/onepassword-sdk-go v0.3.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
Expand All @@ -39,7 +41,6 @@ require (
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cli/safeexec v1.0.1 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
Expand All @@ -57,7 +58,6 @@ require (
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/extism/go-sdk v1.7.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
Expand Down
6 changes: 3 additions & 3 deletions internal/tiger/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import (
"fmt"
"os"
"strings"
"syscall"

"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/timescale/tiger-cli/internal/tiger/api"
"github.com/timescale/tiger-cli/internal/tiger/config"
"github.com/timescale/tiger-cli/internal/tiger/util"
)

// validateAPIKeyForLogin can be overridden for testing
Expand Down Expand Up @@ -215,7 +215,7 @@ func flagOrEnvVar(flagVal, envVarName string) string {
// promptForCredentials prompts the user to enter any missing credentials
func promptForCredentials(consoleURL string, creds credentials) (credentials, error) {
// Check if we're in a terminal for interactive input
if !term.IsTerminal(int(syscall.Stdin)) {
if !util.IsTerminal(os.Stdin) {
return credentials{}, fmt.Errorf("TTY not detected - credentials required. Use flags (--public-key, --secret-key, --project-id) or environment variables (TIGER_PUBLIC_KEY, TIGER_SECRET_KEY, TIGER_PROJECT_ID)")
}

Expand All @@ -236,7 +236,7 @@ func promptForCredentials(consoleURL string, creds credentials) (credentials, er
// Prompt for secret key if missing
if creds.secretKey == "" {
fmt.Print("Enter your secret key: ")
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
bytePassword, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
return credentials{}, err
}
Expand Down
60 changes: 36 additions & 24 deletions internal/tiger/cmd/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,16 +401,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 @@ -419,7 +420,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 @@ -779,23 +782,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 @@ -805,12 +812,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 @@ -961,7 +967,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 @@ -977,17 +989,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 @@ -997,25 +1010,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 @@ -1223,15 +1233,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 @@ -1242,7 +1252,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
143 changes: 143 additions & 0 deletions internal/tiger/cmd/spinner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package cmd

import (
"fmt"
"io"
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/timescale/tiger-cli/internal/tiger/util"
)

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

type Spinner interface {
// Update changes the spinner's displayed message.
Update(message string, args ...any)

// Stop terminates the spinner program and waits for it to finish.
Stop()
}

// NewSpinner creates and returns a new [Spinner] for displaying animated
// status messages. If output is a terminal, it uses bubbletea to dynamically
// update the spinner and message in place. If output is not a terminal, it
// prints each message on a new line without animation. The message parameter
// supports fmt.Sprintf-style formatting with optional args.
func NewSpinner(output io.Writer, message string, args ...any) Spinner {
if util.IsTerminal(output) {
return newAnimatedSpinner(output, message, args...)
}
return newManualSpinner(output, message, args...)
}

type animatedSpinner struct {
program *tea.Program
}

func newAnimatedSpinner(output io.Writer, message string, args ...any) *animatedSpinner {
program := tea.NewProgram(
spinnerModel{
message: fmt.Sprintf(message, args...),
},
tea.WithOutput(output),
)

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

return &animatedSpinner{
program: program,
}
}

// Update changes the spinner's displayed message and triggers bubbletea to re-render.
func (s *animatedSpinner) Update(message string, args ...any) {
s.program.Send(updateMsg(fmt.Sprintf(message, args...)))
}

// Stop quits the [tea.Program] and waits for it to finish.
func (s *animatedSpinner) Stop() {
s.program.Quit()
s.program.Wait()
}

type manualSpinner struct {
output io.Writer
model *spinnerModel
}

func newManualSpinner(output io.Writer, message string, args ...any) *manualSpinner {
s := &manualSpinner{
output: output,
model: &spinnerModel{
message: fmt.Sprintf(message, args...),
},
}
s.printLine()
return s
}

// Update prints the message on a new line if it differs from the previous one.
func (s *manualSpinner) Update(message string, args ...any) {
message = fmt.Sprintf(message, args...)
if message == s.model.message {
return
}

s.model.message = message
s.model.incFrame()
s.printLine()
}

// Stop is a no-op for a manual spinner.
func (s *manualSpinner) Stop() {}

func (s *manualSpinner) printLine() {
fmt.Fprintln(s.output, s.model.View())
}

// Message types for the [tea.Model].
type (
tickMsg struct{}
updateMsg string
)

// spinnerModel is the [tea.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
Loading