diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66f4e288..5ce77d91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 + - 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 diff --git a/CLAUDE.md b/CLAUDE.md index 7edc260b..6affe044 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/cmd/root.go b/cmd/root.go index 5a104f0d..103bca04 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -53,6 +53,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { newLogsCmd(), newConfigCmd(), newVersionCmd(), + newUpdateCmd(cfg), ) return root diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 00000000..7cdabf25 --- /dev/null +++ b/cmd/update.go @@ -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) + }, + } + + cmd.Flags().BoolVar(&checkOnly, "check", false, "Only check for updates without applying them") + + return cmd +} diff --git a/internal/container/logs.go b/internal/container/logs.go index 57fb5c24..a557f86c 100644 --- a/internal/container/logs.go +++ b/internal/container/logs.go @@ -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 diff --git a/internal/env/env.go b/internal/env/env.go index c3703b55..80e480f0 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -18,6 +18,7 @@ type Env struct { AnalyticsEndpoint string NonInteractive bool + GitHubToken string } // Init initializes environment variable configuration and returns the result. @@ -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"), } } diff --git a/internal/output/events.go b/internal/output/events.go index c9e05316..615189ae 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -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 { @@ -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 } // Emit sends an event to the sink with compile-time type safety via generics. @@ -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 diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index 8b7db72f..eaec3942 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -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 diff --git a/internal/output/plain_sink_test.go b/internal/output/plain_sink_test.go index dc6ebf9d..b55bb872 100644 --- a/internal/output/plain_sink_test.go +++ b/internal/output/plain_sink_test.go @@ -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()) } @@ -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 { @@ -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) } diff --git a/internal/ui/app.go b/internal/ui/app.go index 5388d8fa..63f97f4e 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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) + } + return a, nil case output.ContainerStatusEvent: if msg.Phase == "pulling" { a.pullProgress = a.pullProgress.Show(msg.Container) diff --git a/internal/ui/run_update.go b/internal/ui/run_update.go new file mode 100644 index 00000000..ebafe295 --- /dev/null +++ b/internal/ui/run_update.go @@ -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 +} diff --git a/internal/update/extract.go b/internal/update/extract.go new file mode 100644 index 00000000..f46d052d --- /dev/null +++ b/internal/update/extract.go @@ -0,0 +1,187 @@ +package update + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + goruntime "runtime" + "strings" +) + +func extractAndReplace(archivePath, exePath, format string) error { + dir, err := os.MkdirTemp("", "lstk-extract-*") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(dir) }() + + switch format { + case "tar.gz": + if err := extractTarGz(archivePath, dir); err != nil { + return fmt.Errorf("extract failed: %w", err) + } + case "zip": + if err := extractZip(archivePath, dir); err != nil { + return fmt.Errorf("extract failed: %w", err) + } + } + + binaryName := "lstk" + if goruntime.GOOS == "windows" { + binaryName = "lstk.exe" + } + + newBinary := filepath.Join(dir, binaryName) + if _, err := os.Stat(newBinary); err != nil { + return fmt.Errorf("binary not found in archive: %w", err) + } + + info, err := os.Stat(exePath) + if err != nil { + return err + } + + // On Windows, a running executable cannot be overwritten but can be renamed. + // Move it out of the way first so we can place the new binary at the original path. + if goruntime.GOOS == "windows" { + oldPath := exePath + ".old" + // Clean up leftover from a previous update; ignore error if it doesn't exist. + if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("cannot remove old binary %s: %w", oldPath, err) + } + if err := os.Rename(exePath, oldPath); err != nil { + return fmt.Errorf("cannot move running binary: %w", err) + } + } + + if err := os.Rename(newBinary, exePath); err != nil { + // Cross-device rename: fall back to copy + return copyFile(newBinary, exePath, info.Mode()) + } + + return os.Chmod(exePath, info.Mode()) +} + +func safePath(destDir, name string) (string, error) { + if filepath.IsAbs(name) { + return "", fmt.Errorf("archive contains absolute path: %s", name) + } + target := filepath.Join(destDir, filepath.Clean(name)) + rel, err := filepath.Rel(destDir, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("archive entry escapes destination: %s", name) + } + return target, nil +} + +func extractTarGz(archivePath, destDir string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + gr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer func() { _ = gr.Close() }() + + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + target, err := safePath(destDir, hdr.Name) + if err != nil { + return err + } + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, hdr.FileInfo().Mode()) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + _ = out.Close() + return err + } + _ = out.Close() + } + } + return nil +} + +func extractZip(archivePath, destDir string) error { + r, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer func() { _ = r.Close() }() + + for _, f := range r.File { + target, err := safePath(destDir, f.Name) + if err != nil { + return err + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + _ = rc.Close() + return err + } + if _, err := io.Copy(out, rc); err != nil { + _ = out.Close() + _ = rc.Close() + return err + } + _ = out.Close() + _ = rc.Close() + } + return nil +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer func() { _ = in.Close() }() + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return err + } + defer func() { _ = out.Close() }() + + _, err = io.Copy(out, in) + return err +} diff --git a/internal/update/github.go b/internal/update/github.go new file mode 100644 index 00000000..73d2bce1 --- /dev/null +++ b/internal/update/github.go @@ -0,0 +1,112 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + goruntime "runtime" +) + +const ( + githubRepo = "localstack/lstk" + latestReleaseURL = "https://api.github.com/repos/" + githubRepo + "/releases/latest" +) + +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []githubAsset `json:"assets"` +} + +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func githubRequest(ctx context.Context, url, token string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return http.DefaultClient.Do(req) +} + +func fetchLatestVersion(ctx context.Context, token string) (string, error) { + resp, err := githubRequest(ctx, latestReleaseURL, token) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned %s", resp.Status) + } + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", err + } + + return release.TagName, nil +} + +func updateBinary(ctx context.Context, tag, token string) error { + ver := normalizeVersion(tag) + assetName := buildAssetName(ver, goruntime.GOOS, goruntime.GOARCH) + + downloadURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", githubRepo, tag, assetName) + + resp, err := githubRequest(ctx, downloadURL, token) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed: %s", resp.Status) + } + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("cannot determine executable path: %w", err) + } + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return fmt.Errorf("cannot resolve executable path: %w", err) + } + + tmpFile, err := os.CreateTemp(filepath.Dir(exe), "lstk-update-*") + if err != nil { + return fmt.Errorf("cannot create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer func() { _ = os.Remove(tmpPath) }() + + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("download write failed: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + if goruntime.GOOS == "windows" { + return extractAndReplace(tmpPath, exe, "zip") + } + return extractAndReplace(tmpPath, exe, "tar.gz") +} + +func buildAssetName(ver, goos, goarch string) string { + ext := "tar.gz" + if goos == "windows" { + ext = "zip" + } + return fmt.Sprintf("lstk_%s_%s_%s.%s", ver, goos, goarch, ext) +} diff --git a/internal/update/homebrew.go b/internal/update/homebrew.go new file mode 100644 index 00000000..eebaf90a --- /dev/null +++ b/internal/update/homebrew.go @@ -0,0 +1,18 @@ +package update + +import ( + "context" + "os/exec" + + "github.com/localstack/lstk/internal/output" +) + +func updateHomebrew(ctx context.Context, sink output.Sink) error { + cmd := exec.CommandContext(ctx, "brew", "upgrade", "localstack/tap/lstk") + w := newLogLineWriter(sink, output.LogSourceBrew) + cmd.Stdout = w + cmd.Stderr = w + err := cmd.Run() + w.Flush() + return err +} diff --git a/internal/update/install_method.go b/internal/update/install_method.go new file mode 100644 index 00000000..a3d279ff --- /dev/null +++ b/internal/update/install_method.go @@ -0,0 +1,89 @@ +package update + +import ( + "os" + "path/filepath" + "strings" +) + +type InstallMethod int + +const ( + InstallBinary InstallMethod = iota // standalone binary download + InstallHomebrew // installed via Homebrew cask + InstallNPM // installed via npm +) + +func (m InstallMethod) String() string { + switch m { + case InstallHomebrew: + return "homebrew" + case InstallNPM: + return "npm" + default: + return "binary" + } +} + +// InstallInfo holds the detected install method and the resolved binary path. +type InstallInfo struct { + Method InstallMethod + ResolvedPath string +} + +// DetectInstallMethod determines how lstk was installed by inspecting the +// resolved path of the running binary. +func DetectInstallMethod() InstallInfo { + exe, err := os.Executable() + if err != nil { + return InstallInfo{Method: InstallBinary} + } + resolved, err := filepath.EvalSymlinks(exe) + if err != nil { + resolved = exe + } + return InstallInfo{ + Method: classifyPath(resolved), + ResolvedPath: resolved, + } +} + +func classifyPath(resolved string) InstallMethod { + cleaned := filepath.Clean(resolved) + segments := strings.Split(cleaned, string(os.PathSeparator)) + + for _, seg := range segments { + lower := strings.ToLower(seg) + if lower == "caskroom" { + return InstallHomebrew + } + if lower == "node_modules" { + return InstallNPM + } + } + + return InstallBinary +} + +// npmProjectDir returns the project directory for a local npm install, +// or empty string for a global install. A local install has a package.json +// in the parent of the node_modules directory. +func npmProjectDir(resolvedPath string) string { + // Walk up to find node_modules, then check for package.json one level above + dir := resolvedPath + for { + parent := filepath.Dir(dir) + if parent == dir { + break + } + if filepath.Base(dir) == "node_modules" { + pkgJSON := filepath.Join(parent, "package.json") + if _, err := os.Stat(pkgJSON); err == nil { + return parent + } + return "" + } + dir = parent + } + return "" +} diff --git a/internal/update/install_method_test.go b/internal/update/install_method_test.go new file mode 100644 index 00000000..2ac64e38 --- /dev/null +++ b/internal/update/install_method_test.go @@ -0,0 +1,106 @@ +package update + +import ( + "os" + "path/filepath" + "testing" +) + +func TestClassifyPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + want InstallMethod + }{ + { + name: "homebrew cask on apple silicon", + path: "/opt/homebrew/Caskroom/lstk/0.3.0/lstk", + want: InstallHomebrew, + }, + { + name: "homebrew cask on intel mac", + path: "/usr/local/Caskroom/lstk/0.3.0/lstk", + want: InstallHomebrew, + }, + { + name: "npm global install", + path: "/Users/someone/.local/share/mise/installs/node/24.8.0/lib/node_modules/@localstack/lstk_darwin_arm64/lstk", + want: InstallNPM, + }, + { + name: "npm global install default prefix", + path: "/usr/local/lib/node_modules/@localstack/lstk_darwin_amd64/lstk", + want: InstallNPM, + }, + { + name: "standalone binary in usr local bin", + path: "/usr/local/bin/lstk", + want: InstallBinary, + }, + { + name: "standalone binary in home dir", + path: "/home/user/bin/lstk", + want: InstallBinary, + }, + { + name: "dev build", + path: "/home/user/Projects/lstk/bin/lstk", + want: InstallBinary, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := classifyPath(tt.path) + if got != tt.want { + t.Fatalf("classifyPath(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} + +func TestNpmProjectDir(t *testing.T) { + t.Parallel() + + t.Run("local install with package.json", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + binaryPath := filepath.Join(dir, "node_modules", "@localstack", "lstk_darwin_arm64", "lstk") + if err := os.MkdirAll(filepath.Dir(binaryPath), 0o755); err != nil { + t.Fatal(err) + } + + got := npmProjectDir(binaryPath) + if got != dir { + t.Fatalf("npmProjectDir() = %q, want %q", got, dir) + } + }) + + t.Run("global install without package.json", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + binaryPath := filepath.Join(dir, "lib", "node_modules", "@localstack", "lstk_darwin_arm64", "lstk") + if err := os.MkdirAll(filepath.Dir(binaryPath), 0o755); err != nil { + t.Fatal(err) + } + + got := npmProjectDir(binaryPath) + if got != "" { + t.Fatalf("npmProjectDir() = %q, want empty string", got) + } + }) + + t.Run("non-npm path", func(t *testing.T) { + t.Parallel() + got := npmProjectDir("/usr/local/bin/lstk") + if got != "" { + t.Fatalf("npmProjectDir() = %q, want empty string", got) + } + }) +} diff --git a/internal/update/npm.go b/internal/update/npm.go new file mode 100644 index 00000000..90c65cab --- /dev/null +++ b/internal/update/npm.go @@ -0,0 +1,24 @@ +package update + +import ( + "context" + "os/exec" + + "github.com/localstack/lstk/internal/output" +) + +func updateNPM(ctx context.Context, sink output.Sink, projectDir string) error { + var cmd *exec.Cmd + if projectDir != "" { + cmd = exec.CommandContext(ctx, "npm", "install", "@localstack/lstk") + cmd.Dir = projectDir + } else { + cmd = exec.CommandContext(ctx, "npm", "install", "-g", "@localstack/lstk") + } + w := newLogLineWriter(sink, output.LogSourceNPM) + cmd.Stdout = w + cmd.Stderr = w + err := cmd.Run() + w.Flush() + return err +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 00000000..7c73b783 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,121 @@ +package update + +import ( + "bytes" + "context" + "fmt" + "strings" + "sync" + + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/version" +) + +// Check reports whether a newer version is available. +// Returns the latest version string and true if an update is available. +func Check(ctx context.Context, sink output.Sink, githubToken string) (string, bool, error) { + current := version.Version() + if current == "dev" { + output.EmitNote(sink, "Running a development build, skipping update check") + return "", false, nil + } + + output.EmitSpinnerStart(sink, "Checking for updates") + latest, err := fetchLatestVersion(ctx, githubToken) + output.EmitSpinnerStop(sink) + if err != nil { + return "", false, fmt.Errorf("failed to check for updates: %w", err) + } + + if normalizeVersion(current) == normalizeVersion(latest) { + output.EmitNote(sink, fmt.Sprintf("Already up to date (%s)", current)) + return latest, false, nil + } + + output.EmitInfo(sink, fmt.Sprintf("Update available: %s → %s", current, latest)) + return latest, true, nil +} + +// Update checks for updates and applies the update if one is available. +func Update(ctx context.Context, sink output.Sink, checkOnly bool, githubToken string) error { + latest, available, err := Check(ctx, sink, githubToken) + if err != nil { + return err + } + if !available || checkOnly { + return nil + } + + info := DetectInstallMethod() + + switch info.Method { + case InstallHomebrew: + output.EmitNote(sink, "Installed through Homebrew, running brew upgrade") + err = updateHomebrew(ctx, sink) + case InstallNPM: + projectDir := npmProjectDir(info.ResolvedPath) + if projectDir != "" { + output.EmitNote(sink, fmt.Sprintf("Installed through npm (local), running npm install in %s", projectDir)) + } else { + output.EmitNote(sink, "Installed through npm (global), running npm install -g") + } + err = updateNPM(ctx, sink, projectDir) + default: + output.EmitSpinnerStart(sink, "Downloading update") + err = updateBinary(ctx, latest, githubToken) + output.EmitSpinnerStop(sink) + } + if err != nil { + return fmt.Errorf("update failed: %w", err) + } + + output.EmitSuccess(sink, fmt.Sprintf("Updated to %s", latest)) + return nil +} + +// logLineWriter adapts an output.Sink into an io.Writer, emitting each +// complete line as a LogLineEvent. Partial writes are buffered until a +// newline arrives. +type logLineWriter struct { + mu sync.Mutex + sink output.Sink + source string + buf []byte +} + +func newLogLineWriter(sink output.Sink, source string) *logLineWriter { + return &logLineWriter{sink: sink, source: source} +} + +func (w *logLineWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + w.buf = append(w.buf, p...) + for { + i := bytes.IndexByte(w.buf, '\n') + if i < 0 { + break + } + line := string(w.buf[:i]) + w.buf = w.buf[i+1:] + if line != "" { + output.EmitLogLine(w.sink, w.source, line) + } + } + return len(p), nil +} + +// Flush emits any remaining buffered content that didn't end with a newline. +func (w *logLineWriter) Flush() { + w.mu.Lock() + defer w.mu.Unlock() + if len(w.buf) > 0 { + output.EmitLogLine(w.sink, w.source, string(w.buf)) + w.buf = nil + } +} + +// normalizeVersion strips a leading "v" prefix for comparison. +func normalizeVersion(v string) string { + return strings.TrimPrefix(v, "v") +} diff --git a/test/integration/update_test.go b/test/integration/update_test.go new file mode 100644 index 00000000..25a87be9 --- /dev/null +++ b/test/integration/update_test.go @@ -0,0 +1,205 @@ +package integration_test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdateCheckCommand(t *testing.T) { + ctx := testContext(t) + + stdout, stderr, err := runLstk(t, ctx, "", nil, "update", "--check") + require.NoError(t, err, "lstk update --check failed: %s", stderr) + + // Dev builds report a note about skipping update check + assert.Contains(t, stdout, "Note:", "should show a note (dev build or up-to-date)") +} + +func TestUpdateCheckCommandNonInteractive(t *testing.T) { + ctx := testContext(t) + + stdout, stderr, err := runLstk(t, ctx, "", nil, "update", "--check", "--non-interactive") + require.NoError(t, err, "lstk update --check --non-interactive failed: %s", stderr) + assert.Contains(t, stdout, "Note:", "should show a note in non-interactive mode") +} + +func requireNPM(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("npm"); err != nil { + t.Skip("npm is not available") + } +} + +func TestUpdateNPMLocalInstall(t *testing.T) { + requireNPM(t) + + ctx := testContext(t) + + // Set up a fake local npm project. + // On Windows, t.TempDir() may return a short 8.3 path (e.g. RUNNER~1) + // while the program resolves the long path. EvalSymlinks normalizes both. + projectDir, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + require.NoError(t, os.WriteFile( + filepath.Join(projectDir, "package.json"), + []byte(`{"name":"test-project","version":"1.0.0","dependencies":{"@localstack/lstk":"*"}}`), + 0o644, + )) + + // Install @localstack/lstk locally so the node_modules structure exists + npmInstall := exec.CommandContext(ctx, "npm", "install", "@localstack/lstk") + npmInstall.Dir = projectDir + out, err := npmInstall.CombinedOutput() + require.NoError(t, err, "npm install failed: %s", string(out)) + + // Build a fake old version binary and replace the one in node_modules + platformPkg := npmPlatformPackage() + binaryName := "lstk" + if runtime.GOOS == "windows" { + binaryName = "lstk.exe" + } + nmBinaryPath := filepath.Join(projectDir, "node_modules", "@localstack", platformPkg, binaryName) + + // Verify the binary exists from npm install + _, err = os.Stat(nmBinaryPath) + require.NoError(t, err, "expected binary at %s after npm install", nmBinaryPath) + + // Build our dev binary with a fake old version into that location + repoRoot, err := filepath.Abs("../..") + require.NoError(t, err) + buildCmd := exec.CommandContext(ctx, "go", "build", + "-ldflags", "-X github.com/localstack/lstk/internal/version.version=0.0.1", + "-o", nmBinaryPath, + ".", + ) + buildCmd.Dir = repoRoot + out, err = buildCmd.CombinedOutput() + require.NoError(t, err, "go build failed: %s", string(out)) + + // Run the binary directly (not through npx) so os.Executable() resolves to the node_modules path + cmd := exec.CommandContext(ctx, nmBinaryPath, "update", "--non-interactive") + cmd.Dir = projectDir + stdout, err := cmd.CombinedOutput() + stdoutStr := string(stdout) + + require.NoError(t, err, "lstk update failed: %s", stdoutStr) + assert.Contains(t, stdoutStr, "npm (local)", "should detect local npm install") + assert.Contains(t, stdoutStr, projectDir, "should show the project directory") + assert.Contains(t, stdoutStr, "Updated to", "should complete the update") +} + +func TestUpdateBinaryInPlace(t *testing.T) { + ctx := testContext(t) + + // Build a fake old version to a temp location + binaryName := "lstk" + if runtime.GOOS == "windows" { + binaryName = "lstk.exe" + } + tmpBinary := filepath.Join(t.TempDir(), binaryName) + repoRoot, err := filepath.Abs("../..") + require.NoError(t, err) + + buildCmd := exec.CommandContext(ctx, "go", "build", + "-ldflags", "-X github.com/localstack/lstk/internal/version.version=0.0.1", + "-o", tmpBinary, + ".", + ) + buildCmd.Dir = repoRoot + out, err := buildCmd.CombinedOutput() + require.NoError(t, err, "go build failed: %s", string(out)) + + // Verify it reports the fake version + verCmd := exec.CommandContext(ctx, tmpBinary, "version") + verOut, err := verCmd.CombinedOutput() + require.NoError(t, err) + assert.Contains(t, string(verOut), "0.0.1") + + // Run update — should download from GitHub and replace itself + updateCmd := exec.CommandContext(ctx, tmpBinary, "update", "--non-interactive") + updateOut, err := updateCmd.CombinedOutput() + updateStr := string(updateOut) + require.NoError(t, err, "lstk update failed: %s", updateStr) + assert.Contains(t, updateStr, "Update available: 0.0.1", "should detect update") + assert.Contains(t, updateStr, "Downloading update", "should download binary") + assert.Contains(t, updateStr, "Updated to", "should complete the update") + + // Verify the binary was actually replaced + verCmd2 := exec.CommandContext(ctx, tmpBinary, "version") + verOut2, err := verCmd2.CombinedOutput() + require.NoError(t, err) + assert.NotContains(t, string(verOut2), "0.0.1", "binary should no longer be the old version") +} + +func requireHomebrew(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("brew"); err != nil { + t.Skip("Homebrew is not available") + } +} + +func homebrewLstkBinaryPath(t *testing.T) string { + t.Helper() + + // Find the Caskroom binary by resolving the brew symlink + brewBin, err := exec.Command("brew", "--prefix").Output() + require.NoError(t, err) + prefix := strings.TrimSpace(string(brewBin)) + + // Look for lstk in the Caskroom + matches, err := filepath.Glob(filepath.Join(prefix, "Caskroom", "lstk", "*", "lstk")) + if err != nil || len(matches) == 0 { + t.Skip("lstk is not installed via Homebrew") + } + return matches[0] +} + +func TestUpdateHomebrew(t *testing.T) { + if os.Getenv("LSTK_TEST_HOMEBREW") != "1" { + t.Skip("Skipping: overwrites real Homebrew binary. Set LSTK_TEST_HOMEBREW=1 to opt in.") + } + requireHomebrew(t) + caskBinary := homebrewLstkBinaryPath(t) + + ctx := testContext(t) + repoRoot, err := filepath.Abs("../..") + require.NoError(t, err) + + // Build a fake old version into the Caskroom location + buildCmd := exec.CommandContext(ctx, "go", "build", + "-ldflags", "-X github.com/localstack/lstk/internal/version.version=0.0.1", + "-o", caskBinary, + ".", + ) + buildCmd.Dir = repoRoot + out, err := buildCmd.CombinedOutput() + require.NoError(t, err, "go build failed: %s", string(out)) + + // Verify it reports the fake version + verCmd := exec.CommandContext(ctx, caskBinary, "version") + verOut, err := verCmd.CombinedOutput() + require.NoError(t, err) + assert.Contains(t, string(verOut), "0.0.1") + + // Run update — should detect Homebrew and run brew upgrade + // Note: brew may consider lstk already up-to-date (its metadata tracks the + // cask version, not the actual binary content), so "Updated to" may or may + // not appear. We verify detection and that brew was invoked without error. + updateCmd := exec.CommandContext(ctx, caskBinary, "update", "--non-interactive") + updateOut, err := updateCmd.CombinedOutput() + updateStr := string(updateOut) + require.NoError(t, err, "lstk update failed: %s", updateStr) + assert.Contains(t, updateStr, "Homebrew", "should detect Homebrew install") + assert.Contains(t, updateStr, "brew upgrade", "should mention brew upgrade") +} + +func npmPlatformPackage() string { + return "lstk_" + runtime.GOOS + "_" + runtime.GOARCH +}