Skip to content

Commit 8364858

Browse files
committed
List endpoint and web app on start
1 parent 2ce11ae commit 8364858

File tree

5 files changed

+260
-2
lines changed

5 files changed

+260
-2
lines changed

internal/container/start.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
stdruntime "runtime"
99
"slices"
10+
"strings"
1011
"time"
1112

1213
"github.com/containerd/errdefs"
@@ -112,10 +113,10 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start
112113
setups := map[config.EmulatorType]postStartSetupFunc{
113114
config.EmulatorAWS: awsconfig.Setup,
114115
}
115-
return runPostStartSetups(ctx, sink, opts.Containers, interactive, opts.LocalStackHost, setups)
116+
return runPostStartSetups(ctx, sink, opts.Containers, interactive, opts.LocalStackHost, opts.WebAppURL, setups)
116117
}
117118

118-
func runPostStartSetups(ctx context.Context, sink output.Sink, containers []config.ContainerConfig, interactive bool, localStackHost string, setups map[config.EmulatorType]postStartSetupFunc) error {
119+
func runPostStartSetups(ctx context.Context, sink output.Sink, containers []config.ContainerConfig, interactive bool, localStackHost, webAppURL string, setups map[config.EmulatorType]postStartSetupFunc) error {
119120
// build ordered list of unique types, keeping the first container config for each
120121
firstByType := map[config.EmulatorType]config.ContainerConfig{}
121122
var uniqueEmulatorTypes []config.EmulatorType
@@ -134,11 +135,20 @@ func runPostStartSetups(ctx context.Context, sink output.Sink, containers []conf
134135
if err := setup(ctx, sink, interactive, resolvedHost); err != nil {
135136
return err
136137
}
138+
emitPostStartPointers(sink, resolvedHost, webAppURL)
137139
}
138140
}
139141
return nil
140142
}
141143

144+
func emitPostStartPointers(sink output.Sink, resolvedHost, webAppURL string) {
145+
output.EmitInfo(sink, fmt.Sprintf("• Endpoint: %s", resolvedHost))
146+
if webAppURL != "" {
147+
output.EmitInfo(sink, fmt.Sprintf("• Web app: %s", strings.TrimRight(webAppURL, "/")))
148+
}
149+
output.EmitInfo(sink, "> Tip: View emulator logs: lstk logs --follow")
150+
}
151+
142152
func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []runtime.ContainerConfig) error {
143153
for _, c := range containers {
144154
// Remove any existing stopped container with the same name

internal/container/start_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package container
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"io"
@@ -27,3 +28,30 @@ func TestStart_ReturnsEarlyIfRuntimeUnhealthy(t *testing.T) {
2728
assert.Contains(t, err.Error(), "runtime not healthy")
2829
assert.True(t, output.IsSilent(err), "error should be silent since it was already emitted")
2930
}
31+
32+
func TestEmitPostStartPointers_WithWebApp(t *testing.T) {
33+
var out bytes.Buffer
34+
sink := output.NewPlainSink(&out)
35+
36+
emitPostStartPointers(sink, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/")
37+
38+
assert.Equal(t, ""+
39+
"• Endpoint: localhost.localstack.cloud:4566\n"+
40+
"• Web app: https://app.localstack.cloud\n"+
41+
"> Tip: View emulator logs: lstk logs --follow\n",
42+
out.String(),
43+
)
44+
}
45+
46+
func TestEmitPostStartPointers_WithoutWebApp(t *testing.T) {
47+
var out bytes.Buffer
48+
sink := output.NewPlainSink(&out)
49+
50+
emitPostStartPointers(sink, "127.0.0.1:4566", "")
51+
52+
assert.Equal(t, ""+
53+
"• Endpoint: 127.0.0.1:4566\n"+
54+
"> Tip: View emulator logs: lstk logs --follow\n",
55+
out.String(),
56+
)
57+
}

internal/ui/components/message.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package components
22

33
import (
4+
"strings"
5+
46
"github.com/localstack/lstk/internal/output"
57
"github.com/localstack/lstk/internal/ui/styles"
68
)
@@ -15,6 +17,13 @@ func RenderMessage(e output.MessageEvent) string {
1517
case output.SeverityWarning:
1618
return prefix + styles.Warning.Render("Warning:") + " " + styles.Message.Render(e.Text)
1719
default:
20+
if isDecorativeMessage(e.Text) {
21+
return styles.SecondaryMessage.Render(e.Text)
22+
}
1823
return styles.Message.Render(e.Text)
1924
}
2025
}
26+
27+
func isDecorativeMessage(text string) bool {
28+
return strings.HasPrefix(text, "• ") || strings.HasPrefix(text, "> Tip:")
29+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package components
2+
3+
import (
4+
"testing"
5+
6+
"github.com/localstack/lstk/internal/output"
7+
"github.com/localstack/lstk/internal/ui/styles"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestRenderMessage_MutesDecorativeInfoLines(t *testing.T) {
12+
tests := []string{
13+
"• Endpoint: localhost.localstack.cloud:4566",
14+
"• Web app: https://app.localstack.cloud",
15+
"> Tip: View emulator logs: lstk logs --follow",
16+
}
17+
18+
for _, text := range tests {
19+
assert.Equal(t, styles.SecondaryMessage.Render(text), RenderMessage(output.MessageEvent{
20+
Severity: output.SeverityInfo,
21+
Text: text,
22+
}))
23+
}
24+
}
25+
26+
func TestRenderMessage_LeavesRegularInfoLinesUnchanged(t *testing.T) {
27+
assert.Equal(t, styles.Message.Render("hello"), RenderMessage(output.MessageEvent{
28+
Severity: output.SeverityInfo,
29+
Text: "hello",
30+
}))
31+
}

internal/update/install_method.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package update
22

33
import (
4+
"bytes"
45
"os"
6+
"os/exec"
57
"path/filepath"
68
"strings"
9+
"syscall"
710
)
811

912
type InstallMethod int
@@ -87,3 +90,180 @@ func npmProjectDir(resolvedPath string) string {
8790
}
8891
return ""
8992
}
93+
94+
// InstallDetection represents a detected lstk installation.
95+
type InstallDetection struct {
96+
Path string
97+
Method InstallMethod
98+
Version string
99+
IsRunning bool
100+
}
101+
102+
// DetectMultipleInstalls scans common installation paths for lstk binaries
103+
// and returns a list of all detected installations with their metadata.
104+
func DetectMultipleInstalls() []InstallDetection {
105+
var detections []InstallDetection
106+
candidates := collectCandidatePaths()
107+
108+
// Track resolved inodes to avoid reporting symlinks to the same binary
109+
type inodeKey struct {
110+
dev, ino uint64
111+
}
112+
seenInodes := make(map[inodeKey]string)
113+
114+
runningExe, _ := os.Executable()
115+
runningResolved := ""
116+
if runningExe != "" {
117+
runningResolved, _ = filepath.EvalSymlinks(runningExe)
118+
if runningResolved == "" {
119+
runningResolved = runningExe
120+
}
121+
}
122+
123+
for _, path := range candidates {
124+
// Verify it's actually an lstk binary
125+
version, ok := getBinaryVersion(path)
126+
if !ok {
127+
continue
128+
}
129+
130+
// Resolve symlinks and check inode
131+
resolved, err := filepath.EvalSymlinks(path)
132+
if err != nil {
133+
resolved = path
134+
}
135+
136+
// Get inode to detect duplicates
137+
info, err := os.Stat(resolved)
138+
if err != nil {
139+
continue
140+
}
141+
142+
// Skip if we've already seen this inode
143+
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
144+
key := inodeKey{dev: uint64(stat.Dev), ino: uint64(stat.Ino)}
145+
if _, ok := seenInodes[key]; ok {
146+
continue
147+
}
148+
seenInodes[key] = resolved
149+
}
150+
151+
method := classifyPath(resolved)
152+
isRunning := resolved == runningResolved
153+
154+
detections = append(detections, InstallDetection{
155+
Path: path,
156+
Method: method,
157+
Version: version,
158+
IsRunning: isRunning,
159+
})
160+
}
161+
162+
return detections
163+
}
164+
165+
// syscallStat is a helper to extract syscall stat data cross-platform
166+
type syscallStat interface {
167+
Sys() any
168+
}
169+
170+
// collectCandidatePaths returns a list of paths to check for lstk installations.
171+
func collectCandidatePaths() []string {
172+
var paths []string
173+
174+
// Add paths from PATH environment variable
175+
pathDirs := filepath.SplitList(os.Getenv("PATH"))
176+
for _, dir := range pathDirs {
177+
if dir == "" {
178+
continue
179+
}
180+
paths = append(paths, filepath.Join(dir, "lstk"))
181+
}
182+
183+
// Homebrew paths (macOS/Linux)
184+
homebrewPaths := []string{
185+
"/opt/homebrew/bin/lstk", // Apple Silicon
186+
"/usr/local/bin/lstk", // Intel Mac / Linuxbrew
187+
"/home/linuxbrew/.linuxbrew/bin/lstk", // Linuxbrew
188+
}
189+
paths = append(paths, homebrewPaths...)
190+
191+
// npm global paths
192+
npmPaths := getNPMGlobalPaths()
193+
paths = append(paths, npmPaths...)
194+
195+
// Common binary directories
196+
commonBinPaths := []string{
197+
filepath.Join(os.Getenv("HOME"), ".local", "bin", "lstk"),
198+
filepath.Join(os.Getenv("HOME"), "bin", "lstk"),
199+
}
200+
paths = append(paths, commonBinPaths...)
201+
202+
return paths
203+
}
204+
205+
// getNPMGlobalPaths returns npm global install paths based on npm config.
206+
func getNPMGlobalPaths() []string {
207+
var paths []string
208+
209+
// Try to get npm prefix via command
210+
cmd := exec.Command("npm", "root", "-g")
211+
out, err := cmd.Output()
212+
if err == nil {
213+
prefix := strings.TrimSpace(string(out))
214+
paths = append(paths, filepath.Join(prefix, ".bin", "lstk"))
215+
paths = append(paths, filepath.Join(prefix, "node_modules", "@localstack", "lstk", "bin", "lstk"))
216+
paths = append(paths, filepath.Join(prefix, "node_modules", "@localstack", "lstk_darwin_arm64", "lstk"))
217+
paths = append(paths, filepath.Join(prefix, "node_modules", "@localstack", "lstk_darwin_amd64", "lstk"))
218+
paths = append(paths, filepath.Join(prefix, "node_modules", "@localstack", "lstk_linux_amd64", "lstk"))
219+
paths = append(paths, filepath.Join(prefix, "node_modules", "@localstack", "lstk_windows_amd64", "lstk.exe"))
220+
}
221+
222+
// Fallback to common npm global prefixes
223+
fallbackPrefixes := []string{
224+
filepath.Join(os.Getenv("HOME"), ".local", "share", "mise", "installs"),
225+
filepath.Join(os.Getenv("HOME"), ".nvm", "versions"),
226+
"/usr/local",
227+
"/opt/homebrew",
228+
}
229+
for _, prefix := range fallbackPrefixes {
230+
if prefix == "" {
231+
continue
232+
}
233+
paths = append(paths,
234+
filepath.Join(prefix, "lib", "node_modules", "@localstack", "lstk", "bin", "lstk"),
235+
filepath.Join(prefix, "lib", "node_modules", "@localstack", "lstk", "lstk"),
236+
)
237+
}
238+
239+
return paths
240+
}
241+
242+
// getBinaryVersion runs the binary with --version and parses the output.
243+
// Returns the version string and true if successful.
244+
func getBinaryVersion(path string) (string, bool) {
245+
if _, err := os.Stat(path); err != nil {
246+
return "", false
247+
}
248+
249+
cmd := exec.Command(path, "--version")
250+
var stdout bytes.Buffer
251+
cmd.Stdout = &stdout
252+
cmd.Stderr = nil // discard stderr
253+
254+
if err := cmd.Run(); err != nil {
255+
return "", false
256+
}
257+
258+
version := strings.TrimSpace(stdout.String())
259+
if version == "" {
260+
return "", false
261+
}
262+
263+
// Basic validation - should look like a version
264+
if !strings.Contains(version, ".") && !strings.HasPrefix(version, "v") {
265+
return "", false
266+
}
267+
268+
return version, true
269+
}

0 commit comments

Comments
 (0)