Skip to content

Commit 83235ac

Browse files
committed
Refine AWS profile setup flow
1 parent 87a0335 commit 83235ac

File tree

11 files changed

+367
-44
lines changed

11 files changed

+367
-44
lines changed

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ When adding a new command that depends on configuration, wire config initializat
5555

5656
Created automatically on first run with defaults. Supports emulator types (aws, snowflake, azure) - currently only aws is implemented.
5757

58+
# Emulator Setup Commands
59+
60+
Use `lstk setup <emulator>` to set up CLI integration for an emulator type:
61+
- `lstk setup aws` — Sets up AWS CLI profile in `~/.aws/config` and `~/.aws/credentials`
62+
63+
This naming avoids AWS-specific "profile" terminology and uses a clear verb for mutation operations.
64+
The deprecated `lstk config profile` command still works but points users to `lstk setup aws`.
65+
5866
Environment variables:
5967
- `LOCALSTACK_AUTH_TOKEN` - Auth token (skips browser login if set)
6068

cmd/config.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/localstack/lstk/internal/config"
77
"github.com/localstack/lstk/internal/env"
88
"github.com/localstack/lstk/internal/telemetry"
9+
"github.com/localstack/lstk/internal/ui"
910
"github.com/spf13/cobra"
1011
)
1112

@@ -14,10 +15,31 @@ func newConfigCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
1415
Use: "config",
1516
Short: "Manage configuration",
1617
}
18+
cmd.AddCommand(newConfigProfileCmd(cfg, tel))
1719
cmd.AddCommand(newConfigPathCmd(cfg, tel))
1820
return cmd
1921
}
2022

23+
func newConfigProfileCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
24+
return &cobra.Command{
25+
Use: "profile",
26+
Short: "Deprecated: use 'lstk setup aws' instead",
27+
PreRunE: initConfig,
28+
RunE: commandWithTelemetry("config profile", tel, func(cmd *cobra.Command, args []string) error {
29+
appConfig, err := config.Get()
30+
if err != nil {
31+
return fmt.Errorf("failed to get config: %w", err)
32+
}
33+
34+
if !isInteractiveMode(cfg) {
35+
return fmt.Errorf("config profile requires an interactive terminal")
36+
}
37+
38+
return ui.RunConfigProfile(cmd.Context(), appConfig.Containers, cfg.LocalStackHost)
39+
}),
40+
}
41+
}
42+
2143
func newConfigPathCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
2244
return &cobra.Command{
2345
Use: "path",

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
5959
newLogoutCmd(cfg, tel, logger),
6060
newStatusCmd(cfg, tel),
6161
newLogsCmd(cfg, tel),
62+
newSetupCmd(cfg, tel),
6263
newConfigCmd(cfg, tel),
6364
newUpdateCmd(cfg, tel),
6465
)

cmd/setup.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/localstack/lstk/internal/config"
7+
"github.com/localstack/lstk/internal/env"
8+
"github.com/localstack/lstk/internal/telemetry"
9+
"github.com/localstack/lstk/internal/ui"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
func newSetupCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
14+
cmd := &cobra.Command{
15+
Use: "setup",
16+
Short: "Set up emulator CLI integration",
17+
Long: "Set up emulator CLI integration (e.g., AWS profile, Azure config).",
18+
}
19+
cmd.AddCommand(newSetupAWSCmd(cfg, tel))
20+
return cmd
21+
}
22+
23+
func newSetupAWSCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
24+
return &cobra.Command{
25+
Use: "aws",
26+
Short: "Set up the LocalStack AWS profile",
27+
Long: "Set up the LocalStack AWS profile in ~/.aws/config and ~/.aws/credentials for use with AWS CLI and SDKs.",
28+
PreRunE: initConfig,
29+
RunE: commandWithTelemetry("setup aws", tel, func(cmd *cobra.Command, args []string) error {
30+
appConfig, err := config.Get()
31+
if err != nil {
32+
return fmt.Errorf("failed to get config: %w", err)
33+
}
34+
35+
if !isInteractiveMode(cfg) {
36+
return fmt.Errorf("setup aws requires an interactive terminal")
37+
}
38+
39+
return ui.RunConfigProfile(cmd.Context(), appConfig.Containers, cfg.LocalStackHost)
40+
}),
41+
}
42+
}

internal/awsconfig/awsconfig.go

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -184,17 +184,47 @@ func writeCredsProfile(credsPath string) error {
184184
return upsertSection(credsPath, credsSectionName, credentialsDefaults())
185185
}
186186

187-
// Setup checks for the localstack AWS profile and prompts to create or update it if needed.
188-
// resolvedHost must be a host:port string (e.g. "localhost.localstack.cloud:4566").
189-
// In non-interactive mode, emits a note instead of prompting.
190-
func Setup(ctx context.Context, sink output.Sink, interactive bool, resolvedHost string) error {
187+
func emitMissingProfileNote(sink output.Sink) {
188+
output.EmitNote(sink, "LocalStack AWS profile is incomplete. Run 'lstk setup aws'.")
189+
}
190+
191+
func needsProfileSetup(resolvedHost string) (profileStatus, error) {
191192
configPath, credsPath, err := awsPaths()
192193
if err != nil {
193-
output.EmitWarning(sink, fmt.Sprintf("could not determine AWS config paths: %v", err))
194-
return nil
194+
return profileStatus{}, err
195195
}
196196

197197
status, err := checkProfileStatus(configPath, credsPath, resolvedHost)
198+
if err != nil {
199+
return profileStatus{}, err
200+
}
201+
202+
return status, nil
203+
}
204+
205+
func profilePresence() (configOK, credsOK bool, err error) {
206+
configPath, credsPath, err := awsPaths()
207+
if err != nil {
208+
return false, false, err
209+
}
210+
211+
configOK, err = sectionExists(configPath, configSectionName)
212+
if err != nil {
213+
return false, false, err
214+
}
215+
credsOK, err = sectionExists(credsPath, credsSectionName)
216+
if err != nil {
217+
return false, false, err
218+
}
219+
220+
return configOK, credsOK, nil
221+
}
222+
223+
// EnsureProfile checks for the LocalStack AWS profile and either emits a note when it is incomplete
224+
// or triggers the interactive setup flow.
225+
// resolvedHost must be a host:port string (e.g. "localhost.localstack.cloud:4566").
226+
func EnsureProfile(ctx context.Context, sink output.Sink, interactive bool, resolvedHost string) error {
227+
status, err := needsProfileSetup(resolvedHost)
198228
if err != nil {
199229
output.EmitWarning(sink, fmt.Sprintf("could not check AWS profile: %v", err))
200230
return nil
@@ -203,21 +233,52 @@ func Setup(ctx context.Context, sink output.Sink, interactive bool, resolvedHost
203233
return nil
204234
}
205235

206-
if !interactive {
207-
output.EmitNote(sink, fmt.Sprintf("No complete LocalStack AWS profile found. Run lstk interactively to configure one, or add a [profile %s] section to ~/.aws/config manually.", profileName))
236+
configOK, credsOK, err := profilePresence()
237+
if err != nil {
238+
output.EmitWarning(sink, fmt.Sprintf("could not check AWS profile presence: %v", err))
239+
return nil
240+
}
241+
if interactive && !configOK && !credsOK {
242+
return Setup(ctx, sink, resolvedHost)
243+
}
244+
245+
emitMissingProfileNote(sink)
246+
return nil
247+
}
248+
249+
// Setup checks for the localstack AWS profile and prompts to create or update it if needed.
250+
// resolvedHost must be a host:port string (e.g. "localhost.localstack.cloud:4566").
251+
func Setup(ctx context.Context, sink output.Sink, resolvedHost string) error {
252+
status, err := needsProfileSetup(resolvedHost)
253+
if err != nil {
254+
output.EmitWarning(sink, fmt.Sprintf("could not check AWS profile: %v", err))
255+
return nil
256+
}
257+
if !status.anyNeeded() {
258+
output.EmitNote(sink, "LocalStack AWS profile is already configured.")
259+
return nil
260+
}
261+
262+
configPath, credsPath, err := awsPaths()
263+
if err != nil {
264+
output.EmitWarning(sink, fmt.Sprintf("could not determine AWS config paths: %v", err))
208265
return nil
209266
}
210267

211268
responseCh := make(chan output.InputResponse, 1)
212269
output.EmitUserInputRequest(sink, output.UserInputRequestEvent{
213-
Prompt: "Configure AWS profile in ~/.aws/?",
270+
Prompt: "Set up a LocalStack profile for AWS CLI and SDKs in ~/.aws?",
214271
Options: []output.InputOption{{Key: "y", Label: "Y"}, {Key: "n", Label: "n"}},
215272
ResponseCh: responseCh,
216273
})
217274

218275
select {
219276
case resp := <-responseCh:
220-
if resp.Cancelled || resp.SelectedKey == "n" {
277+
if resp.Cancelled {
278+
return nil
279+
}
280+
if resp.SelectedKey == "n" {
281+
output.EmitNote(sink, "Skipped adding LocalStack AWS profile.")
221282
return nil
222283
}
223284
if status.configNeeded {
@@ -232,12 +293,10 @@ func Setup(ctx context.Context, sink output.Sink, interactive bool, resolvedHost
232293
return nil
233294
}
234295
}
235-
output.EmitSuccess(sink, "AWS profile successfully configured")
236-
output.EmitNote(sink, fmt.Sprintf("Try: aws s3 mb s3://test --profile %s", profileName))
296+
output.EmitSuccess(sink, "Created LocalStack profile in ~/.aws/config")
237297
case <-ctx.Done():
238298
return ctx.Err()
239299
}
240300

241301
return nil
242302
}
243-

internal/config/containers.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,14 @@ func (c *ContainerConfig) ProductName() (string, error) {
138138
}
139139
return productName, nil
140140
}
141+
142+
// GetAWSContainer returns the AWS container config from the list of containers.
143+
// Returns nil if no AWS container is configured.
144+
func GetAWSContainer(containers []ContainerConfig) *ContainerConfig {
145+
for i := range containers {
146+
if containers[i].Type == EmulatorAWS {
147+
return &containers[i]
148+
}
149+
}
150+
return nil
151+
}

internal/container/start.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start
184184
// Maps emulator types to their post-start setup functions.
185185
// Add an entry here to run setup for a new emulator type (e.g. Azure, Snowflake).
186186
setups := map[config.EmulatorType]postStartSetupFunc{
187-
config.EmulatorAWS: awsconfig.Setup,
187+
config.EmulatorAWS: awsconfig.EnsureProfile,
188188
}
189189
return runPostStartSetups(ctx, sink, opts.Containers, interactive, opts.LocalStackHost, opts.WebAppURL, setups)
190190
}

internal/ui/app.go

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9696
}
9797
if a.pendingInput != nil {
9898
if opt := resolveOption(a.pendingInput.Options, msg); opt != nil {
99-
a.lines = appendLine(a.lines, styledLine{text: formatResolvedInput(*a.pendingInput, opt.Key)})
10099
responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: opt.Key})
101100
a.pendingInput = nil
102-
a.inputPrompt = a.inputPrompt.Hide()
101+
a.inputPrompt = components.NewInputPrompt()
102+
a.spinner = a.spinner.SetText("")
103103
return a, responseCmd
104104
}
105105
}
@@ -296,24 +296,13 @@ func (a *App) flushBufferedLines() {
296296
}
297297

298298
func formatResolvedInput(req output.UserInputRequestEvent, selectedKey string) string {
299-
formatted := output.FormatPrompt(req.Prompt, req.Options)
300-
firstLine := strings.Split(formatted, "\n")[0]
301-
302299
selected := selectedKey
303-
hasLabels := false
304300
for _, opt := range req.Options {
305-
if opt.Label != "" {
306-
hasLabels = true
307-
}
308301
if opt.Key == selectedKey && opt.Label != "" {
309302
selected = opt.Label
310303
}
311304
}
312-
313-
if selected == "" || !hasLabels || selectedKey == "any" {
314-
return firstLine
315-
}
316-
return fmt.Sprintf("%s %s", firstLine, selected)
305+
return selected
317306
}
318307

319308
// resolveOption finds the best matching option for a key event, in priority order:

internal/ui/components/input_prompt_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@ func TestInputPromptView(t *testing.T) {
3838
},
3939
{
4040
name: "multiple options shows brackets",
41-
prompt: "Configure AWS profile?",
41+
prompt: "Set up a LocalStack profile for AWS CLI and SDKs in ~/.aws?",
4242
options: []output.InputOption{
4343
{Key: "y", Label: "Y"},
4444
{Key: "n", Label: "n"},
4545
},
46-
contains: []string{"?", "Configure AWS profile?", "[Y/n]"},
46+
contains: []string{"?", "Set up a LocalStack profile for AWS CLI and SDKs in ~/.aws?", "[Y/n]"},
4747
},
4848
{
4949
name: "multi-line prompt renders trailing lines",

internal/ui/run_awsconfig.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package ui
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"os"
8+
9+
tea "github.com/charmbracelet/bubbletea"
10+
"github.com/localstack/lstk/internal/awsconfig"
11+
"github.com/localstack/lstk/internal/config"
12+
"github.com/localstack/lstk/internal/endpoint"
13+
"github.com/localstack/lstk/internal/output"
14+
)
15+
16+
// RunConfigProfile runs the AWS profile setup flow with TUI output.
17+
// It resolves the host from the AWS container config and runs the setup.
18+
func RunConfigProfile(parentCtx context.Context, containers []config.ContainerConfig, localStackHost string) error {
19+
awsContainer := config.GetAWSContainer(containers)
20+
if awsContainer == nil {
21+
return fmt.Errorf("no aws emulator configured")
22+
}
23+
24+
resolvedHost, dnsOK := endpoint.ResolveHost(awsContainer.Port, localStackHost)
25+
if !dnsOK {
26+
output.EmitNote(output.NewTUISink(programSender{}), `Could not resolve "localhost.localstack.cloud" - your system may have DNS rebind protection enabled. Using 127.0.0.1 as the endpoint.`)
27+
}
28+
29+
ctx, cancel := context.WithCancel(parentCtx)
30+
defer cancel()
31+
32+
app := NewApp("", "", "", cancel, withoutHeader())
33+
p := tea.NewProgram(app, tea.WithInput(os.Stdin), tea.WithOutput(os.Stdout))
34+
runErrCh := make(chan error, 1)
35+
36+
go func() {
37+
err := awsconfig.Setup(ctx, output.NewTUISink(programSender{p: p}), resolvedHost)
38+
runErrCh <- err
39+
if err != nil && !errors.Is(err, context.Canceled) {
40+
p.Send(runErrMsg{err: err})
41+
return
42+
}
43+
p.Send(runDoneMsg{})
44+
}()
45+
46+
model, err := p.Run()
47+
if err != nil {
48+
return err
49+
}
50+
51+
if app, ok := model.(App); ok && app.Err() != nil {
52+
return output.NewSilentError(app.Err())
53+
}
54+
55+
runErr := <-runErrCh
56+
if runErr != nil && !errors.Is(runErr, context.Canceled) {
57+
return runErr
58+
}
59+
60+
return nil
61+
}
62+

0 commit comments

Comments
 (0)