Skip to content

Commit 9a637db

Browse files
authored
Refine AWS profile setup flow (#146)
1 parent 1254a96 commit 9a637db

15 files changed

Lines changed: 455 additions & 65 deletions

File tree

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

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ To see which config file is currently in use:
8383
lstk config path
8484
```
8585

86+
You can also configure AWS CLI integration:
87+
88+
```bash
89+
lstk setup aws
90+
```
91+
92+
This sets up a `localstack` profile in `~/.aws/config` and `~/.aws/credentials`.
93+
8694
You can also point `lstk` at a specific config file for any command:
8795

8896
```bash
@@ -184,6 +192,9 @@ lstk update
184192
# Show resolved config file path
185193
lstk config path
186194

195+
# Set up AWS CLI profile integration
196+
lstk setup aws
197+
187198
```
188199

189200
## Reporting bugs

cmd/config.go

Lines changed: 23 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,32 @@ 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+
// Delegate to the same handler as "lstk setup aws"
39+
return ui.RunConfigProfile(cmd.Context(), appConfig.Containers, cfg.LocalStackHost)
40+
}),
41+
}
42+
}
43+
2144
func newConfigPathCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
2245
return &cobra.Command{
2346
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
newDocsCmd(),

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, Snowflake, Azure). Currently only AWS is supported.",
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: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,12 @@ func (s profileStatus) anyNeeded() bool {
7676
return s.configNeeded || s.credsNeeded
7777
}
7878

79-
// checkProfileStatus determines which AWS profile files need to be written or updated.
80-
func checkProfileStatus(configPath, credsPath, resolvedHost string) (profileStatus, error) {
79+
// CheckProfileStatus determines which AWS profile files need to be written or updated.
80+
func CheckProfileStatus(resolvedHost string) (profileStatus, error) {
81+
configPath, credsPath, err := awsPaths()
82+
if err != nil {
83+
return profileStatus{}, err
84+
}
8185
configNeeded, err := configNeedsWrite(configPath, resolvedHost)
8286
if err != nil {
8387
return profileStatus{}, err
@@ -184,40 +188,84 @@ func writeCredsProfile(credsPath string) error {
184188
return upsertSection(credsPath, credsSectionName, credentialsDefaults())
185189
}
186190

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 {
191+
func emitMissingProfileNote(sink output.Sink) {
192+
output.EmitNote(sink, "LocalStack AWS profile is incomplete. Run 'lstk setup aws'.")
193+
}
194+
195+
// checkProfileSetup returns both the profile status (which files need writing) and presence (which files exist).
196+
// This avoids loading the same files twice by combining needsProfileSetup and profilePresence.
197+
func checkProfileSetup(resolvedHost string) (profileStatus, bool, bool, error) {
191198
configPath, credsPath, err := awsPaths()
192199
if err != nil {
193-
output.EmitWarning(sink, fmt.Sprintf("could not determine AWS config paths: %v", err))
194-
return nil
200+
return profileStatus{}, false, false, err
201+
}
202+
203+
status, err := CheckProfileStatus(resolvedHost)
204+
if err != nil {
205+
return profileStatus{}, false, false, err
206+
}
207+
208+
configOK, err := sectionExists(configPath, configSectionName)
209+
if err != nil {
210+
return profileStatus{}, false, false, err
211+
}
212+
credsOK, err := sectionExists(credsPath, credsSectionName)
213+
if err != nil {
214+
return profileStatus{}, false, false, err
195215
}
196216

197-
status, err := checkProfileStatus(configPath, credsPath, resolvedHost)
217+
return status, configOK, credsOK, nil
218+
}
219+
220+
// EnsureProfile checks for the LocalStack AWS profile and either emits a note when it is incomplete
221+
// or triggers the interactive setup flow.
222+
// resolvedHost must be a host:port string (e.g. "localhost.localstack.cloud:4566").
223+
func EnsureProfile(ctx context.Context, sink output.Sink, interactive bool, resolvedHost string) error {
224+
status, configOK, credsOK, err := checkProfileSetup(resolvedHost)
198225
if err != nil {
199226
output.EmitWarning(sink, fmt.Sprintf("could not check AWS profile: %v", err))
200227
return nil
201228
}
202229
if !status.anyNeeded() {
203230
return nil
204231
}
232+
if interactive && !configOK && !credsOK {
233+
return Setup(ctx, sink, resolvedHost, status)
234+
}
235+
236+
emitMissingProfileNote(sink)
237+
return nil
238+
}
239+
240+
// Setup checks for the localstack AWS profile and prompts to create or update it if needed.
241+
// resolvedHost must be a host:port string (e.g. "localhost.localstack.cloud:4566").
242+
// status is passed in from EnsureProfile to avoid re-checking the profile status.
243+
func Setup(ctx context.Context, sink output.Sink, resolvedHost string, status profileStatus) error {
244+
if !status.anyNeeded() {
245+
output.EmitNote(sink, "LocalStack AWS profile is already configured.")
246+
return nil
247+
}
205248

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))
249+
configPath, credsPath, err := awsPaths()
250+
if err != nil {
251+
output.EmitWarning(sink, fmt.Sprintf("could not determine AWS config paths: %v", err))
208252
return nil
209253
}
210254

211255
responseCh := make(chan output.InputResponse, 1)
212256
output.EmitUserInputRequest(sink, output.UserInputRequestEvent{
213-
Prompt: "Configure AWS profile in ~/.aws/?",
257+
Prompt: "Set up a LocalStack profile for AWS CLI and SDKs in ~/.aws?",
214258
Options: []output.InputOption{{Key: "y", Label: "Y"}, {Key: "n", Label: "n"}},
215259
ResponseCh: responseCh,
216260
})
217261

218262
select {
219263
case resp := <-responseCh:
220-
if resp.Cancelled || resp.SelectedKey == "n" {
264+
if resp.Cancelled {
265+
return nil
266+
}
267+
if resp.SelectedKey == "n" {
268+
output.EmitNote(sink, "Skipped adding LocalStack AWS profile.")
221269
return nil
222270
}
223271
if status.configNeeded {
@@ -232,12 +280,16 @@ func Setup(ctx context.Context, sink output.Sink, interactive bool, resolvedHost
232280
return nil
233281
}
234282
}
235-
output.EmitSuccess(sink, "AWS profile successfully configured")
236-
output.EmitNote(sink, fmt.Sprintf("Try: aws s3 mb s3://test --profile %s", profileName))
283+
if status.configNeeded && status.credsNeeded {
284+
output.EmitSuccess(sink, "Created LocalStack profile in ~/.aws")
285+
} else if status.configNeeded {
286+
output.EmitSuccess(sink, "Created LocalStack profile in ~/.aws/config")
287+
} else {
288+
output.EmitSuccess(sink, "Updated LocalStack credentials in ~/.aws/credentials")
289+
}
237290
case <-ctx.Done():
238291
return ctx.Err()
239292
}
240293

241294
return nil
242295
}
243-

internal/awsconfig/awsconfig_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,8 @@ func TestCheckProfileStatus(t *testing.T) {
199199
if tc.credsContent != "" {
200200
writeFile(t, credsPath, tc.credsContent)
201201
}
202-
status, err := checkProfileStatus(configPath, credsPath, tc.resolvedHost)
202+
t.Setenv("HOME", dir)
203+
status, err := CheckProfileStatus(tc.resolvedHost)
203204
if err != nil {
204205
t.Fatal(err)
205206
}
@@ -221,7 +222,9 @@ func TestCheckProfileStatusMalformedFile(t *testing.T) {
221222
writeFile(t, configPath, "this is not valid \x00\x01\x02 ini content [[[")
222223
writeFile(t, credsPath, "[localstack]\naws_access_key_id = test\naws_secret_access_key = test\n")
223224

224-
_, err := checkProfileStatus(configPath, credsPath, "127.0.0.1:4566")
225+
// Override HOME to use our test directory
226+
t.Setenv("HOME", dir)
227+
_, err := CheckProfileStatus("127.0.0.1:4566")
225228
if err == nil {
226229
t.Error("expected error for malformed config file, got nil")
227230
}

internal/config/containers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,4 @@ func (c *ContainerConfig) ProductName() (string, error) {
137137
return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type)
138138
}
139139
return productName, nil
140-
}
140+
}

internal/container/start.go

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

internal/ui/app.go

Lines changed: 2 additions & 23 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
}
@@ -295,27 +295,6 @@ func (a *App) flushBufferedLines() {
295295
a.bufferedLines = nil
296296
}
297297

298-
func formatResolvedInput(req output.UserInputRequestEvent, selectedKey string) string {
299-
formatted := output.FormatPrompt(req.Prompt, req.Options)
300-
firstLine := strings.Split(formatted, "\n")[0]
301-
302-
selected := selectedKey
303-
hasLabels := false
304-
for _, opt := range req.Options {
305-
if opt.Label != "" {
306-
hasLabels = true
307-
}
308-
if opt.Key == selectedKey && opt.Label != "" {
309-
selected = opt.Label
310-
}
311-
}
312-
313-
if selected == "" || !hasLabels || selectedKey == "any" {
314-
return firstLine
315-
}
316-
return fmt.Sprintf("%s %s", firstLine, selected)
317-
}
318-
319298
// resolveOption finds the best matching option for a key event, in priority order:
320299
// 1. "any" — matches any keypress
321300
// 2. "enter" — matches the Enter key explicitly

0 commit comments

Comments
 (0)