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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,19 @@ jobs:
go.sum
test/integration/go.sum

# Install lstk via Homebrew so the Homebrew update integration test
# (TestUpdateHomebrew) can locate a real Caskroom binary path.
- name: Install lstk via Homebrew
if: matrix.os == 'macos-latest'
run: brew install localstack/tap/lstk
Comment thread
silv-io marked this conversation as resolved.

- name: Run integration tests
run: make test-integration
env:
CREATE_JUNIT_REPORT: "true"
LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }}
LSTK_TEST_HOMEBREW: ${{ matrix.os == 'macos-latest' && '1' || '0' }}
LSTK_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Upload test results
uses: actions/upload-artifact@v7
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Note: Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for
- `config/` - Viper-based TOML config loading and path resolution
- `output/` - Generic event and sink abstractions for CLI/TUI/non-interactive rendering
- `ui/` - Bubble Tea views for interactive output
- `update/` - Self-update logic: version check via GitHub API, binary/Homebrew/npm update paths, archive extraction

# Configuration

Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
newLogsCmd(),
newConfigCmd(),
newVersionCmd(),
newUpdateCmd(cfg),
)

return root
Expand Down
32 changes: 32 additions & 0 deletions cmd/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package cmd

import (
"os"

"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/ui"
"github.com/localstack/lstk/internal/update"
"github.com/spf13/cobra"
)

func newUpdateCmd(cfg *env.Env) *cobra.Command {
var checkOnly bool

cmd := &cobra.Command{
Use: "update",
Short: "Update lstk to the latest version",
Long: "Check for and apply updates to the lstk CLI. Respects the original installation method (Homebrew, npm, or direct binary).",
PreRunE: initConfig,
RunE: func(cmd *cobra.Command, args []string) error {
if isInteractiveMode(cfg) {
return ui.RunUpdate(cmd.Context(), checkOnly, cfg.GitHubToken)
}
return update.Update(cmd.Context(), output.NewPlainSink(os.Stdout), checkOnly, cfg.GitHubToken)
},
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

cmd.Flags().BoolVar(&checkOnly, "check", false, "Only check for updates without applying them")

return cmd
}
Comment thread
silv-io marked this conversation as resolved.
2 changes: 1 addition & 1 deletion internal/container/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func Logs(ctx context.Context, rt runtime.Runtime, sink output.Sink, follow bool

scanner := bufio.NewScanner(pr)
for scanner.Scan() {
output.EmitContainerLogLine(sink, scanner.Text())
output.EmitLogLine(sink, output.LogSourceEmulator, scanner.Text())
}
if err := scanner.Err(); err != nil && ctx.Err() == nil {
return err
Expand Down
2 changes: 2 additions & 0 deletions internal/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Env struct {
AnalyticsEndpoint string

NonInteractive bool
GitHubToken string
}

// Init initializes environment variable configuration and returns the result.
Expand All @@ -39,6 +40,7 @@ func Init() *Env {
WebAppURL: viper.GetString("web_app_url"),
ForceFileKeyring: viper.GetString("keyring") == "file",
AnalyticsEndpoint: viper.GetString("analytics_endpoint"),
GitHubToken: viper.GetString("github_token"),
}

}
17 changes: 12 additions & 5 deletions internal/output/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ type AuthEvent struct {
}

type Event interface {
MessageEvent | AuthEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | ContainerLogLineEvent
MessageEvent | AuthEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | LogLineEvent
}

type Sink interface {
Expand Down Expand Up @@ -105,8 +105,15 @@ type UserInputRequestEvent struct {
ResponseCh chan<- InputResponse
}

type ContainerLogLineEvent struct {
Line string
const (
LogSourceEmulator = "emulator"
LogSourceBrew = "brew"
LogSourceNPM = "npm"
)

type LogLineEvent struct {
Source string // use LogSource* constants
Line string
Comment thread
silv-io marked this conversation as resolved.
}

// Emit sends an event to the sink with compile-time type safety via generics.
Expand Down Expand Up @@ -155,8 +162,8 @@ func EmitAuth(sink Sink, event AuthEvent) {
Emit(sink, event)
}

func EmitContainerLogLine(sink Sink, line string) {
Emit(sink, ContainerLogLineEvent{Line: line})
func EmitLogLine(sink Sink, source, line string) {
Emit(sink, LogLineEvent{Source: source, Line: line})
}

const DefaultSpinnerMinDuration = 400 * time.Millisecond
Expand Down
2 changes: 1 addition & 1 deletion internal/output/plain_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func FormatEventLine(event any) (string, bool) {
return "", false
case UserInputRequestEvent:
return formatUserInputRequest(e), true
case ContainerLogLineEvent:
case LogLineEvent:
return e.Line, true
default:
return "", false
Expand Down
7 changes: 5 additions & 2 deletions internal/output/plain_sink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,11 @@ func TestPlainSink_SuppressesProgressEvent(t *testing.T) {
assert.Equal(t, "", out.String())
}

func TestPlainSink_EmitsContainerLogLineEvent(t *testing.T) {
func TestPlainSink_EmitsLogLineEvent(t *testing.T) {
var out bytes.Buffer
sink := NewPlainSink(&out)

Emit(sink, ContainerLogLineEvent{Line: "2024-01-01 hello from container"})
Emit(sink, LogLineEvent{Source: "container", Line: "2024-01-01 hello from container"})

assert.Equal(t, "2024-01-01 hello from container\n", out.String())
Comment thread
silv-io marked this conversation as resolved.
}
Expand Down Expand Up @@ -188,6 +188,7 @@ func TestPlainSink_UsesFormatterParity(t *testing.T) {
SpinnerEvent{Active: true, Text: "Loading"},
ErrorEvent{Title: "Failed", Summary: "Something broke"},
ContainerStatusEvent{Phase: "starting", Container: "localstack"},
LogLineEvent{Source: "container", Line: "2024-01-01 hello"},
}

for _, event := range events {
Expand All @@ -205,6 +206,8 @@ func TestPlainSink_UsesFormatterParity(t *testing.T) {
Emit(sink, e)
case ContainerStatusEvent:
Emit(sink, e)
case LogLineEvent:
Emit(sink, e)
default:
t.Fatalf("unsupported event type in test: %T", event)
}
Expand Down
9 changes: 9 additions & 0 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.lines = appendLine(a.lines, styledLine{text: msg.URL, secondary: true})
}
return a, nil
case output.LogLineEvent:
prefix := styles.Secondary.Render(msg.Source + " | ")
line := styledLine{text: prefix + styles.Message.Render(msg.Line)}
if a.spinner.PendingStop() {
a.bufferedLines = append(a.bufferedLines, line)
} else {
a.lines = appendLine(a.lines, line)
Comment thread
silv-io marked this conversation as resolved.
}
return a, nil
case output.ContainerStatusEvent:
if msg.Phase == "pulling" {
a.pullProgress = a.pullProgress.Show(msg.Container)
Expand Down
46 changes: 46 additions & 0 deletions internal/ui/run_update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package ui

import (
"context"
"errors"
"os"

tea "github.com/charmbracelet/bubbletea"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/update"
)

func RunUpdate(parentCtx context.Context, checkOnly bool, githubToken string) error {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()

app := NewApp("", "", "", cancel, withoutHeader())
p := tea.NewProgram(app, tea.WithInput(os.Stdin), tea.WithOutput(os.Stdout))
runErrCh := make(chan error, 1)

go func() {
err := update.Update(ctx, output.NewTUISink(programSender{p: p}), checkOnly, githubToken)
runErrCh <- err
if err != nil && !errors.Is(err, context.Canceled) {
p.Send(runErrMsg{err: err})
return
}
p.Send(runDoneMsg{})
}()

model, err := p.Run()
if err != nil {
return err
}

if app, ok := model.(App); ok && app.Err() != nil {
return output.NewSilentError(app.Err())
}

runErr := <-runErrCh
if runErr != nil && !errors.Is(runErr, context.Canceled) {
return runErr
}

return nil
}
Loading
Loading