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
7 changes: 4 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,10 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
}

notifyOpts := update.NotifyOptions{
GitHubToken: cfg.GitHubToken,
UpdatePrompt: appConfig.UpdatePrompt,
PersistDisable: config.DisableUpdatePrompt,
GitHubToken: cfg.GitHubToken,
UpdatePrompt: true,
SkippedVersion: appConfig.CLI.UpdateSkippedVersion,
PersistSkipVersion: config.SetUpdateSkippedVersion,
}

configPath, err := config.FriendlyConfigPath()
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/docker/go-connections v0.7.0
github.com/google/uuid v1.6.0
github.com/muesli/termenv v0.16.0
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
Expand Down Expand Up @@ -67,7 +68,6 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
Expand Down
56 changes: 49 additions & 7 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,24 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/pelletier/go-toml/v2"
"github.com/spf13/viper"
)

//go:embed default_config.toml
var defaultConfigTemplate string

type CLIConfig struct {
UpdateSkippedVersion string `mapstructure:"update_skipped_version"`
}

type Config struct {
Containers []ContainerConfig `mapstructure:"containers"`
Env map[string]map[string]string `mapstructure:"env"`
UpdatePrompt bool `mapstructure:"update_prompt"`
Containers []ContainerConfig `mapstructure:"containers"`
Env map[string]map[string]string `mapstructure:"env"`
CLI CLIConfig `mapstructure:"cli"`
}

func setDefaults() {
Expand All @@ -27,7 +34,6 @@ func setDefaults() {
"port": "4566",
},
})
viper.SetDefault("update_prompt", true)
}

func loadConfig(path string) error {
Expand Down Expand Up @@ -109,11 +115,47 @@ func resolvedConfigPath() string {

func Set(key string, value any) error {
viper.Set(key, value)
return viper.WriteConfig()
return setInFile(viper.ConfigFileUsed(), key, value)
}

// setInFile inserts or updates a single "section.field" key in the TOML config
// file without rewriting unrelated content, preserving comments and formatting.
func setInFile(path, key string, value any) error {
parts := strings.SplitN(key, ".", 2)
if len(parts) != 2 {
return viper.WriteConfig()
}
section, field := parts[0], parts[1]

// Encode value using go-toml for correct scalar quoting.
type wrapper struct {
V any `toml:"v"`
}
enc, err := toml.Marshal(wrapper{V: value})
if err != nil {
return fmt.Errorf("failed to encode value: %w", err)
}
line := strings.TrimSpace(string(enc))
assignment := field + " =" + line[strings.IndexByte(line, '=')+1:]

data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
content := string(data)

re := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(field) + `\s*=.*$`)
if re.MatchString(content) {
content = re.ReplaceAllString(content, assignment)
} else {
content = strings.TrimRight(content, "\n") + "\n\n[" + section + "]\n" + assignment + "\n"
}

return os.WriteFile(path, []byte(content), 0644)
}

func DisableUpdatePrompt() error {
return Set("update_prompt", false)
func SetUpdateSkippedVersion(version string) error {
return Set("cli.update_skipped_version", version)
}

func Get() (*Config, error) {
Expand Down
123 changes: 123 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package config

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/pelletier/go-toml/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSetInFileAppendsWhenKeyAbsent(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
original := `# User comment
[[containers]]
type = "aws"
port = "4566"
`
require.NoError(t, os.WriteFile(path, []byte(original), 0644))

require.NoError(t, setInFile(path, "cli.update_skipped_version", "v1.2.3"))

got, err := os.ReadFile(path)
require.NoError(t, err)
result := string(got)

assert.Contains(t, result, "# User comment")
assert.Contains(t, result, `type = "aws"`)
assert.Contains(t, result, "[cli]")
assert.Contains(t, result, `update_skipped_version = 'v1.2.3'`)

var parsed map[string]any
require.NoError(t, toml.Unmarshal(got, &parsed))
}

func TestSetInFileReplacesExistingKeyInPlace(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
original := `# Keep this
[[containers]]
type = "aws"

[cli]
update_skipped_version = "v1.0.0"
`
require.NoError(t, os.WriteFile(path, []byte(original), 0644))

require.NoError(t, setInFile(path, "cli.update_skipped_version", "v2.0.0"))

got, err := os.ReadFile(path)
require.NoError(t, err)
result := string(got)

assert.Contains(t, result, "# Keep this")
assert.Contains(t, result, `update_skipped_version = 'v2.0.0'`)
assert.NotContains(t, result, "v1.0.0")

var parsed map[string]any
require.NoError(t, toml.Unmarshal(got, &parsed))
}

func TestSetInFilePreservesCommentsAndFormatting(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
original := `# lstk configuration file
# Run 'lstk config path' to see where this file lives.

[[containers]]
type = "aws" # Emulator type
tag = "latest" # Docker image tag
port = "4566" # Host port

# Example profiles:
# [env.debug]
# DEBUG = "1"
`
require.NoError(t, os.WriteFile(path, []byte(original), 0644))

require.NoError(t, setInFile(path, "cli.update_skipped_version", "v1.2.3"))

got, err := os.ReadFile(path)
require.NoError(t, err)
result := string(got)

for _, want := range []string{
"# lstk configuration file",
"# Run 'lstk config path' to see where this file lives.",
"# Emulator type",
"# Docker image tag",
"# Host port",
"# Example profiles:",
`# DEBUG = "1"`,
} {
assert.Contains(t, result, want, "expected comment preserved: %q", want)
}
}

func TestSetInFileIsIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
require.NoError(t, os.WriteFile(path, []byte("[[containers]]\ntype = \"aws\"\n"), 0644))

require.NoError(t, setInFile(path, "cli.update_skipped_version", "v1.0.0"))
require.NoError(t, setInFile(path, "cli.update_skipped_version", "v2.0.0"))
require.NoError(t, setInFile(path, "cli.update_skipped_version", "v3.0.0"))

got, err := os.ReadFile(path)
require.NoError(t, err)

var parsed struct {
CLI struct {
UpdateSkippedVersion string `toml:"update_skipped_version"`
} `toml:"cli"`
}
require.NoError(t, toml.Unmarshal(got, &parsed))
assert.Equal(t, "v3.0.0", parsed.CLI.UpdateSkippedVersion)

assert.Equal(t, 1, strings.Count(string(got), "update_skipped_version"))
}

func TestSetInFileErrorsOnMissingFile(t *testing.T) {
err := setInFile(filepath.Join(t.TempDir(), "nonexistent.toml"), "cli.update_skipped_version", "v1.0.0")
assert.Error(t, err)
}
1 change: 1 addition & 0 deletions internal/output/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ type UserInputRequestEvent struct {
Prompt string
Options []InputOption
ResponseCh chan<- InputResponse
Vertical bool
}

const (
Expand Down
63 changes: 62 additions & 1 deletion internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Quit
}
if a.pendingInput != nil {
if a.pendingInput.Vertical {
return a.handleVerticalPromptKey(msg)
}
Comment thread
gtsiolis marked this conversation as resolved.
if opt := resolveOption(a.pendingInput.Options, msg); opt != nil {
responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: opt.Key})
a.pendingInput = nil
Expand Down Expand Up @@ -149,7 +152,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.spinner.Visible() {
a.spinner = a.spinner.SetText(output.FormatPrompt(msg.Prompt, msg.Options))
} else {
a.inputPrompt = a.inputPrompt.Show(msg.Prompt, msg.Options)
a.inputPrompt = a.inputPrompt.Show(msg.Prompt, msg.Options, msg.Vertical)
}
case spinner.TickMsg:
var cmd tea.Cmd
Expand Down Expand Up @@ -318,6 +321,64 @@ func (a *App) flushBufferedLines() {
a.bufferedLines = nil
}

func (a App) handleVerticalPromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyUp:
a.inputPrompt = a.inputPrompt.SetSelectedIndex(a.inputPrompt.SelectedIndex() - 1)
return a, nil
case tea.KeyDown:
a.inputPrompt = a.inputPrompt.SetSelectedIndex(a.inputPrompt.SelectedIndex() + 1)
return a, nil
case tea.KeyEnter:
idx := a.inputPrompt.SelectedIndex()
if idx >= 0 && idx < len(a.pendingInput.Options) {
opt := a.pendingInput.Options[idx]
a.lines = appendLine(a.lines, styledLine{text: formatResolvedInput(*a.pendingInput, opt.Key)})
responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: opt.Key})
a.pendingInput = nil
a.inputPrompt = a.inputPrompt.Hide()
return a, responseCmd
}
}
Comment thread
gtsiolis marked this conversation as resolved.
if opt := resolveOption(a.pendingInput.Options, msg); opt != nil {
a.lines = appendLine(a.lines, styledLine{text: formatResolvedInput(*a.pendingInput, opt.Key)})
responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: opt.Key})
a.pendingInput = nil
a.inputPrompt = a.inputPrompt.Hide()
return a, responseCmd
}
return a, nil
}

func formatResolvedInput(req output.UserInputRequestEvent, selectedKey string) string {
selected := selectedKey
hasLabels := false
for _, opt := range req.Options {
if opt.Label != "" {
hasLabels = true
}
if opt.Key == selectedKey && opt.Label != "" {
selected = opt.Label
}
}

if req.Vertical {
firstLine := strings.Split(req.Prompt, "\n")[0]
if selected == "" || !hasLabels || selectedKey == "any" {
return firstLine
}
return fmt.Sprintf("%s %s", firstLine, selected)
}

formatted := output.FormatPrompt(req.Prompt, req.Options)
firstLine := strings.Split(formatted, "\n")[0]

if selected == "" || !hasLabels || selectedKey == "any" {
return firstLine
}
return fmt.Sprintf("%s %s", firstLine, selected)
}

// resolveOption finds the best matching option for a key event, in priority order:
// 1. "any" — matches any keypress
// 2. "enter" — matches the Enter key explicitly
Expand Down
38 changes: 38 additions & 0 deletions internal/ui/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,44 @@ func TestAppEnterDoesNothingWithNonLetterLabel(t *testing.T) {
}
}

func TestAppEnterSelectsHighlightedVerticalOption(t *testing.T) {
t.Parallel()

app := NewApp("dev", "", "", nil)
responseCh := make(chan output.InputResponse, 1)

model, _ := app.Update(output.UserInputRequestEvent{
Prompt: "Update lstk to latest version?",
Options: []output.InputOption{{Key: "u", Label: "Update now [U]"}, {Key: "s", Label: "Skip this version [S]"}, {Key: "n", Label: "Never ask again [N]"}},
ResponseCh: responseCh,
Vertical: true,
})
app = model.(App)

model, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown})
app = model.(App)

model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter})
app = model.(App)
if cmd == nil {
t.Fatal("expected response command when enter is pressed on vertical prompt")
}
cmd()

select {
case resp := <-responseCh:
if resp.SelectedKey != "s" {
t.Fatalf("expected s key, got %q", resp.SelectedKey)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for response on channel")
}

if app.inputPrompt.Visible() {
t.Fatal("expected input prompt to be hidden after response")
}
}

func TestAppAnyKeyOptionResolvesOnAnyKeypress(t *testing.T) {
t.Parallel()

Expand Down
Loading