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) + } +}