From 66c4a090106e46b02fc06078150c90760dc4dc83 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 14 Oct 2026 16:42:28 +0000 Subject: [PATCH] tui: write .env, .gitignore and secrets/ next to the compose file Secret conversion used to write .env, .gitignore and secrets/ into the shell's working directory. Compose resolves .env (project directory) and the file: paths of top-level secrets relative to the compose file, so opening ~/proj/compose.yaml from another directory scattered those files in the wrong place and left the stack broken. The model now remembers the directory of the compose file it is bound to (set on import and on successful save) and convertSecretCmd writes everything there. A session not yet bound to a file keeps the old behavior (working directory). Tests cover both strategies, the unbound fallback, and the binding on import/save. --- internal/tui/model.go | 10 +++ internal/tui/secrets_dialog.go | 23 ++++-- internal/tui/secrets_dialog_test.go | 105 ++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 4 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 1c3b225..db526ca 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -95,6 +95,14 @@ type Model struct { // first. dirty bool + // composeDir is the directory of the compose file this session is + // bound to: set when a file is imported or successfully saved. + // .env, .gitignore and secrets/ are written there, because that is + // where Compose resolves them (not the shell's working directory). + // Empty until the session is bound to a file; callers then fall + // back to the working directory. + composeDir string + quitting bool } @@ -172,6 +180,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case saveResult: m.lastSave = msg if msg.err == nil { + m.composeDir = dirOf(msg.filename) m.dirty = false m.currentMode = modeSaved m.syncViewportHeight() @@ -955,6 +964,7 @@ func (m *Model) importFile(path string) error { return err } m.config = imported + m.composeDir = dirOf(path) // The working copy now differs from whatever's on disk at the // default save path until it's explicitly saved, same as any other // change — this isn't itself the file at "docker-compose.yml". diff --git a/internal/tui/secrets_dialog.go b/internal/tui/secrets_dialog.go index 32c6f24..8213c48 100644 --- a/internal/tui/secrets_dialog.go +++ b/internal/tui/secrets_dialog.go @@ -18,6 +18,9 @@ import ( // ConvertSecretToEnvFile/ConvertSecretToComposeSecret only mutate the // in-memory config and hand back what needs writing. func (m *Model) convertSecretCmd(service, key string, strategy int) tea.Cmd { + // Captured now, not inside the closure: the command runs later on + // another goroutine, and the model may have been copied by then. + dir := m.composeDir return func() tea.Msg { switch strategy { case 0: // .env @@ -25,11 +28,11 @@ func (m *Model) convertSecretCmd(service, key string, strategy int) tea.Cmd { if err != nil { return secretActionResult{err: err} } - if err := appendEnvFileLine(".env", line); err != nil { + if err := appendEnvFileLine(filepath.Join(dir, ".env"), line); err != nil { return secretActionResult{err: fmt.Errorf("updating .env: %w", err)} } msg := fmt.Sprintf("Moved %s to .env.", key) - if err := ensureGitignoreEntry(".gitignore", ".env"); err != nil { + if err := ensureGitignoreEntry(filepath.Join(dir, ".gitignore"), ".env"); err != nil { msg += " Couldn't update .gitignore automatically — make sure .env is ignored yourself." } else { msg += " Added .env to .gitignore." @@ -41,14 +44,17 @@ func (m *Model) convertSecretCmd(service, key string, strategy int) tea.Cmd { if err != nil { return secretActionResult{err: err} } - if err := writeSecretFile(path, value); err != nil { + // path is "./secrets/.txt", relative to the compose + // file (that is how the top-level secrets: entry refers to + // it), so it is written relative to the compose file too. + if err := writeSecretFile(filepath.Join(dir, path), value); err != nil { return secretActionResult{err: fmt.Errorf("writing %s: %w", path, err)} } msg := fmt.Sprintf( "Moved %s to a Compose secret ('%s', %s) — the service now reads it "+ "from /run/secrets/%s instead of the environment.", key, secretName, path, secretName) - if err := ensureGitignoreEntry(".gitignore", "secrets/"); err != nil { + if err := ensureGitignoreEntry(filepath.Join(dir, ".gitignore"), "secrets/"); err != nil { msg += " Couldn't update .gitignore automatically — make sure secrets/ is ignored yourself." } else { msg += " Added secrets/ to .gitignore." @@ -59,6 +65,15 @@ func (m *Model) convertSecretCmd(service, key string, strategy int) tea.Cmd { } } +// dirOf returns the absolute directory of a compose file path, or the +// plain directory part if the working directory can't be resolved. +func dirOf(path string) string { + if abs, err := filepath.Abs(path); err == nil { + return filepath.Dir(abs) + } + return filepath.Dir(path) +} + // ensureGitignoreEntry appends entry to a .gitignore file as its own // line, creating the file if it doesn't exist. If entry (or the same // path with/without a trailing slash — ".env" and ".env/" are treated diff --git a/internal/tui/secrets_dialog_test.go b/internal/tui/secrets_dialog_test.go index cb1eaad..4f95637 100644 --- a/internal/tui/secrets_dialog_test.go +++ b/internal/tui/secrets_dialog_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "runtime" "testing" + + "github.com/casablanque-code/komposer/pkg/composer" ) func TestEnsureGitignoreEntry_CreatesFileIfMissing(t *testing.T) { @@ -135,3 +137,106 @@ func TestWriteSecretFile_CreatesParentDirAndWritesValue(t *testing.T) { } } } + +// newSecretTestModel returns a model bound to dir with one service +// holding a hardcoded POSTGRES_PASSWORD. +func newSecretTestModel(t *testing.T, dir string) *Model { + t.Helper() + cfg := composer.NewComposeConfig() + svc := cfg.AddService("db") + svc.Image = "postgres:16" + svc.Environment = []string{"POSTGRES_PASSWORD=hunter2"} + return &Model{config: cfg, composeDir: dir} +} + +func TestConvertSecretCmd_EnvFileGoesNextToComposeFile(t *testing.T) { + dir := t.TempDir() + cwd := t.TempDir() + t.Chdir(cwd) // the shell's working directory must stay untouched + + m := newSecretTestModel(t, dir) + res := m.convertSecretCmd("db", "POSTGRES_PASSWORD", 0)().(secretActionResult) + if res.err != nil { + t.Fatalf("unexpected error: %v", res.err) + } + + env, err := os.ReadFile(filepath.Join(dir, ".env")) + if err != nil { + t.Fatalf(".env not written next to compose file: %v", err) + } + if string(env) != "POSTGRES_PASSWORD=hunter2\n" { + t.Fatalf(".env = %q", env) + } + if _, err := os.Stat(filepath.Join(dir, ".gitignore")); err != nil { + t.Fatalf(".gitignore not written next to compose file: %v", err) + } + for _, name := range []string{".env", ".gitignore"} { + if _, err := os.Stat(filepath.Join(cwd, name)); err == nil { + t.Fatalf("%s leaked into the working directory", name) + } + } +} + +func TestConvertSecretCmd_ComposeSecretGoesNextToComposeFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file mode bits are not meaningful on Windows") + } + dir := t.TempDir() + cwd := t.TempDir() + t.Chdir(cwd) + + m := newSecretTestModel(t, dir) + res := m.convertSecretCmd("db", "POSTGRES_PASSWORD", 1)().(secretActionResult) + if res.err != nil { + t.Fatalf("unexpected error: %v", res.err) + } + + got, err := os.ReadFile(filepath.Join(dir, "secrets", "db_postgres_password.txt")) + if err != nil { + t.Fatalf("secret file not written next to compose file: %v", err) + } + if string(got) != "hunter2" { + t.Fatalf("secret content = %q", got) + } + if _, err := os.Stat(filepath.Join(cwd, "secrets")); err == nil { + t.Fatal("secrets/ leaked into the working directory") + } +} + +func TestConvertSecretCmd_UnboundModelFallsBackToWorkingDir(t *testing.T) { + cwd := t.TempDir() + t.Chdir(cwd) + + m := newSecretTestModel(t, "") + res := m.convertSecretCmd("db", "POSTGRES_PASSWORD", 0)().(secretActionResult) + if res.err != nil { + t.Fatalf("unexpected error: %v", res.err) + } + if _, err := os.Stat(filepath.Join(cwd, ".env")); err != nil { + t.Fatalf(".env not written to working directory: %v", err) + } +} + +func TestImportFile_BindsComposeDir(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "compose.yaml") + if err := os.WriteFile(path, []byte("services:\n web:\n image: nginx:1.27\n"), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + m := &Model{config: composer.NewComposeConfig()} + if err := m.importFile(path); err != nil { + t.Fatalf("import: %v", err) + } + if m.composeDir != dir { + t.Fatalf("composeDir = %q, want %q", m.composeDir, dir) + } +} + +func TestSaveResult_BindsComposeDir(t *testing.T) { + dir := t.TempDir() + m := Model{config: composer.NewComposeConfig(), dirty: true} + next, _ := m.Update(saveResult{filename: filepath.Join(dir, "compose.yaml")}) + if got := next.(Model).composeDir; got != dir { + t.Fatalf("composeDir = %q, want %q", got, dir) + } +}