detached a3a5b69

Merge pull request #151 from bnema/feat/remote-config

feat(remote): add remote configuration
commit
a3a5b6925f796ffc357735f56bd27fb89bb1168f
author
bnema · · 2026-07-30 06:03 UTC
committer
GitHub · · 2026-07-30 06:03 UTC
merge of
e1f87b8 + 0356602 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +956 -1
M internal/adapters/config/config.go +26 -1
@@ -21,11 +21,13 @@ const pollInterval = 2 * time.Second
2121 var (
2222 processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
2323 percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)
24+ sectionPattern = regexp.MustCompile(`^\[([^\]]+)\]$`)
2425 )
2526
2627 // Parse reads vev's flat action = value config format. Duplicate action keys
2728 // are accepted with a warning; the last value wins while first-seen action order
28-// is preserved for binding conflict resolution.
29+// is preserved for binding conflict resolution. An optional [remote] section
30+// accepts enabled/remember/hosts with TOML-style true/false values.
2931 func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3032 cfg := domain.Defaults()
3133 var warnings []domain.Warning
@@ -35,6 +37,8 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3537 seenPaletteKeys := make(map[string]bool)
3638 seenNavKeys := make(map[string]bool)
3739 seenTabsKeys := make(map[string]bool)
40+ seenRemoteKeys := make(map[string]bool)
41+ section := ""
3842
3943 scanner := bufio.NewScanner(r)
4044 lineNo := 0
@@ -49,6 +53,17 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
4953 continue
5054 }
5155
56+ if matches := sectionPattern.FindStringSubmatch(line); matches != nil {
57+ name := matches[1]
58+ if name == "remote" {
59+ section = "remote"
60+ } else {
61+ section = "unknown:" + name
62+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown section %q", name)})
63+ }
64+ continue
65+ }
66+
5267 key, value, ok := strings.Cut(line, "=")
5368 if !ok {
5469 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
@@ -61,6 +76,16 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
6176 continue
6277 }
6378
79+ if strings.HasPrefix(section, "unknown:") {
80+ continue
81+ }
82+ if section == "remote" {
83+ var remoteWarnings []domain.Warning
84+ cfg.Remote, remoteWarnings = parseRemoteKey(cfg.Remote, seenRemoteKeys, key, value, lineNo)
85+ warnings = append(warnings, remoteWarnings...)
86+ continue
87+ }
88+
6489 switch {
6590 case key == "theme":
6691 mode, ok := parseTheme(value)
@@ -21,11 +21,13 @@ const pollInterval = 2 * time.Second
21 var (21 var (
22 processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)22 processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
23 percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)23 percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)
24+ sectionPattern = regexp.MustCompile(`^\[([^\]]+)\]$`)
24 )25 )
25 26
26 // Parse reads vev's flat action = value config format. Duplicate action keys27 // Parse reads vev's flat action = value config format. Duplicate action keys
27 // are accepted with a warning; the last value wins while first-seen action order28 // are accepted with a warning; the last value wins while first-seen action order
28-// is preserved for binding conflict resolution.29+// is preserved for binding conflict resolution. An optional [remote] section
30+// accepts enabled/remember/hosts with TOML-style true/false values.
29 func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {31 func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
30 cfg := domain.Defaults()32 cfg := domain.Defaults()
31 var warnings []domain.Warning33 var warnings []domain.Warning
@@ -35,6 +37,8 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
35 seenPaletteKeys := make(map[string]bool)37 seenPaletteKeys := make(map[string]bool)
36 seenNavKeys := make(map[string]bool)38 seenNavKeys := make(map[string]bool)
37 seenTabsKeys := make(map[string]bool)39 seenTabsKeys := make(map[string]bool)
40+ seenRemoteKeys := make(map[string]bool)
41+ section := ""
38 42
39 scanner := bufio.NewScanner(r)43 scanner := bufio.NewScanner(r)
40 lineNo := 044 lineNo := 0
@@ -49,6 +53,17 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
49 continue53 continue
50 }54 }
51 55
56+ if matches := sectionPattern.FindStringSubmatch(line); matches != nil {
57+ name := matches[1]
58+ if name == "remote" {
59+ section = "remote"
60+ } else {
61+ section = "unknown:" + name
62+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown section %q", name)})
63+ }
64+ continue
65+ }
66+
52 key, value, ok := strings.Cut(line, "=")67 key, value, ok := strings.Cut(line, "=")
53 if !ok {68 if !ok {
54 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})69 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
@@ -61,6 +76,16 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
61 continue76 continue
62 }77 }
63 78
79+ if strings.HasPrefix(section, "unknown:") {
80+ continue
81+ }
82+ if section == "remote" {
83+ var remoteWarnings []domain.Warning
84+ cfg.Remote, remoteWarnings = parseRemoteKey(cfg.Remote, seenRemoteKeys, key, value, lineNo)
85+ warnings = append(warnings, remoteWarnings...)
86+ continue
87+ }
88+
64 switch {89 switch {
65 case key == "theme":90 case key == "theme":
66 mode, ok := parseTheme(value)91 mode, ok := parseTheme(value)
M internal/adapters/config/config_test.go +3 -0
@@ -358,6 +358,9 @@ func TestParse(t *testing.T) {
358358 if !tt.want.Palette.AnchorSet {
359359 tt.want.Palette = domain.Defaults().Palette
360360 }
361+ if tt.want.Remote.Hosts == nil {
362+ tt.want.Remote = domain.Defaults().Remote
363+ }
361364 got, warnings, err := Parse(strings.NewReader(tt.input))
362365 if err != nil {
363366 t.Fatalf("Parse() error = %v", err)
@@ -358,6 +358,9 @@ func TestParse(t *testing.T) {
358 if !tt.want.Palette.AnchorSet {358 if !tt.want.Palette.AnchorSet {
359 tt.want.Palette = domain.Defaults().Palette359 tt.want.Palette = domain.Defaults().Palette
360 }360 }
361+ if tt.want.Remote.Hosts == nil {
362+ tt.want.Remote = domain.Defaults().Remote
363+ }
361 got, warnings, err := Parse(strings.NewReader(tt.input))364 got, warnings, err := Parse(strings.NewReader(tt.input))
362 if err != nil {365 if err != nil {
363 t.Fatalf("Parse() error = %v", err)366 t.Fatalf("Parse() error = %v", err)
A internal/adapters/config/remote.go +328 -0
@@ -0,0 +1,328 @@
1+package config
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "syscall"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/pkg/safedir"
15+)
16+
17+func parseRemoteKey(remote domain.RemoteConfig, seen map[string]bool, key, value string, lineNo int) (domain.RemoteConfig, []domain.Warning) {
18+ var warnings []domain.Warning
19+ switch key {
20+ case "enabled", "remember", "hosts":
21+ warnings = warnDuplicateKey(warnings, seen, key, lineNo)
22+ default:
23+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown key %q", key)})
24+ return remote, warnings
25+ }
26+
27+ switch key {
28+ case "enabled":
29+ on, ok := parseTrueFalse(value)
30+ if !ok {
31+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid enabled %q", value)})
32+ return remote, warnings
33+ }
34+ remote.Enabled = on
35+ case "remember":
36+ on, ok := parseTrueFalse(value)
37+ if !ok {
38+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remember %q", value)})
39+ return remote, warnings
40+ }
41+ remote.Remember = on
42+ case "hosts":
43+ hosts, hostWarnings := parseRemoteHosts(value, lineNo)
44+ warnings = append(warnings, hostWarnings...)
45+ if hosts == nil {
46+ return remote, warnings
47+ }
48+ remote.Hosts = hosts
49+ }
50+ return remote, warnings
51+}
52+
53+func parseTrueFalse(value string) (bool, bool) {
54+ switch strings.TrimSpace(value) {
55+ case "true":
56+ return true, true
57+ case "false":
58+ return false, true
59+ default:
60+ return false, false
61+ }
62+}
63+
64+func parseRemoteHosts(value string, lineNo int) ([]string, []domain.Warning) {
65+ var raw json.RawMessage
66+ if err := json.Unmarshal([]byte(value), &raw); err != nil {
67+ return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
68+ }
69+ if len(raw) == 0 || raw[0] != '[' {
70+ return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
71+ }
72+ var items []any
73+ if err := json.Unmarshal(raw, &items); err != nil {
74+ return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
75+ }
76+ out := make([]string, 0, len(items))
77+ seen := make(map[string]struct{}, len(items))
78+ var warnings []domain.Warning
79+ ok := true
80+ for _, item := range items {
81+ host, isString := item.(string)
82+ if !isString {
83+ return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
84+ }
85+ if err := domain.ValidateRemoteHostTarget(host); err != nil {
86+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remote host %q: %v", host, err)})
87+ ok = false
88+ continue
89+ }
90+ if _, dup := seen[host]; dup {
91+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate host %q", host)})
92+ ok = false
93+ continue
94+ }
95+ seen[host] = struct{}{}
96+ out = append(out, host)
97+ }
98+ if !ok {
99+ return nil, warnings
100+ }
101+ return out, nil
102+}
103+
104+// UpdateRemoteHosts creates or replaces the pinned hosts list in path's [remote]
105+// section while preserving unrelated config text. Writes use a private parent
106+// directory and same-directory temp + sync + rename + directory sync.
107+func UpdateRemoteHosts(path string, hosts []string) error {
108+ hosts = domain.UniqueRemoteHostTargets(hosts)
109+ for _, host := range hosts {
110+ if err := domain.ValidateRemoteHostTarget(host); err != nil {
111+ return err
112+ }
113+ }
114+ if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
115+ return err
116+ }
117+
118+ lockFile, err := acquireConfigLock(path)
119+ if err != nil {
120+ return err
121+ }
122+ defer func() {
123+ _ = releaseConfigLock(lockFile)
124+ }()
125+
126+ original, err := os.ReadFile(path)
127+ if err != nil && !errors.Is(err, os.ErrNotExist) {
128+ return err
129+ }
130+ missing := errors.Is(err, os.ErrNotExist)
131+
132+ hostsLine, err := formatRemoteHostsLine(hosts)
133+ if err != nil {
134+ return err
135+ }
136+
137+ var next []byte
138+ if missing || len(bytes.TrimSpace(original)) == 0 {
139+ next = []byte("[remote]\n" + hostsLine + "\n")
140+ } else {
141+ next = rewriteRemoteHosts(original, hostsLine)
142+ }
143+ return writeConfigAtomic(path, next)
144+}
145+
146+func formatRemoteHostsLine(hosts []string) (string, error) {
147+ var b strings.Builder
148+ b.WriteString("hosts = [")
149+ for i, host := range hosts {
150+ if i > 0 {
151+ b.WriteString(", ")
152+ }
153+ encoded, err := json.Marshal(host)
154+ if err != nil {
155+ return "", err
156+ }
157+ b.Write(encoded)
158+ }
159+ b.WriteByte(']')
160+ return b.String(), nil
161+}
162+
163+func rewriteRemoteHosts(original []byte, hostsLine string) []byte {
164+ lines := splitKeepNewlines(original)
165+ remoteIdx := -1
166+ hostsIdx := -1
167+ inRemote := false
168+ for i, line := range lines {
169+ trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"))
170+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
171+ continue
172+ }
173+ trimmed = stripInlineComment(trimmed)
174+ if matches := sectionPattern.FindStringSubmatch(trimmed); matches != nil {
175+ inRemote = matches[1] == "remote"
176+ if inRemote && remoteIdx < 0 {
177+ remoteIdx = i
178+ }
179+ continue
180+ }
181+ if !inRemote {
182+ continue
183+ }
184+ key, _, ok := strings.Cut(trimmed, "=")
185+ if !ok {
186+ continue
187+ }
188+ if strings.TrimSpace(key) == "hosts" {
189+ hostsIdx = i
190+ }
191+ }
192+
193+ ending := dominantLineEnding(original)
194+
195+ switch {
196+ case hostsIdx >= 0:
197+ prefix := lines[hostsIdx]
198+ nl := "\n"
199+ if strings.HasSuffix(prefix, "\r\n") {
200+ nl = "\r\n"
201+ } else if !strings.HasSuffix(prefix, "\n") {
202+ nl = ""
203+ }
204+ lines[hostsIdx] = hostsLine + nl
205+ case remoteIdx >= 0:
206+ insertAt := remoteIdx + 1
207+ for insertAt < len(lines) {
208+ trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(lines[insertAt], "\n"), "\r"))
209+ if matches := sectionPattern.FindStringSubmatch(stripInlineComment(trimmed)); matches != nil {
210+ break
211+ }
212+ insertAt++
213+ }
214+ hostsEntry := hostsLine + ending
215+ lines = append(lines[:insertAt], append([]string{hostsEntry}, lines[insertAt:]...)...)
216+ default:
217+ if len(lines) > 0 {
218+ last := lines[len(lines)-1]
219+ if last != "" && !strings.HasSuffix(last, "\n") && !strings.HasSuffix(last, "\r\n") {
220+ lines[len(lines)-1] = last + ending
221+ }
222+ }
223+ lines = append(lines, "[remote]"+ending, hostsLine+ending)
224+ }
225+
226+ var b strings.Builder
227+ for _, line := range lines {
228+ b.WriteString(line)
229+ }
230+ return []byte(b.String())
231+}
232+
233+func dominantLineEnding(data []byte) string {
234+ crlf := 0
235+ lf := 0
236+ for i := 0; i < len(data); i++ {
237+ if data[i] != '\n' {
238+ continue
239+ }
240+ if i > 0 && data[i-1] == '\r' {
241+ crlf++
242+ } else {
243+ lf++
244+ }
245+ }
246+ if crlf > lf {
247+ return "\r\n"
248+ }
249+ return "\n"
250+}
251+
252+func acquireConfigLock(path string) (*os.File, error) {
253+ file, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
254+ if err != nil {
255+ return nil, err
256+ }
257+ if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
258+ _ = file.Close()
259+ return nil, err
260+ }
261+ return file, nil
262+}
263+
264+func releaseConfigLock(file *os.File) error {
265+ if file == nil {
266+ return nil
267+ }
268+ return errors.Join(syscall.Flock(int(file.Fd()), syscall.LOCK_UN), file.Close())
269+}
270+
271+func splitKeepNewlines(data []byte) []string {
272+ if len(data) == 0 {
273+ return nil
274+ }
275+ var lines []string
276+ start := 0
277+ for i := 0; i < len(data); i++ {
278+ if data[i] == '\n' {
279+ lines = append(lines, string(data[start:i+1]))
280+ start = i + 1
281+ }
282+ }
283+ if start < len(data) {
284+ lines = append(lines, string(data[start:]))
285+ }
286+ return lines
287+}
288+
289+func writeConfigAtomic(path string, data []byte) error {
290+ dir := filepath.Dir(path)
291+ file, err := os.CreateTemp(dir, ".vev-config-*.tmp")
292+ if err != nil {
293+ return err
294+ }
295+ tmp := file.Name()
296+ committed := false
297+ defer func() {
298+ if !committed {
299+ _ = file.Close()
300+ _ = os.Remove(tmp)
301+ }
302+ }()
303+ if err := file.Chmod(0o600); err != nil {
304+ return err
305+ }
306+ if _, err := file.Write(data); err != nil {
307+ return err
308+ }
309+ if err := file.Sync(); err != nil {
310+ return err
311+ }
312+ if err := file.Close(); err != nil {
313+ return err
314+ }
315+ if err := os.Rename(tmp, path); err != nil {
316+ return err
317+ }
318+ committed = true
319+ return syncDir(dir)
320+}
321+
322+func syncDir(dir string) error {
323+ file, err := os.Open(dir)
324+ if err != nil {
325+ return err
326+ }
327+ return errors.Join(file.Sync(), file.Close())
328+}
@@ -0,0 +1,328 @@
1+package config
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "syscall"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/pkg/safedir"
15+)
16+
17+func parseRemoteKey(remote domain.RemoteConfig, seen map[string]bool, key, value string, lineNo int) (domain.RemoteConfig, []domain.Warning) {
18+ var warnings []domain.Warning
19+ switch key {
20+ case "enabled", "remember", "hosts":
21+ warnings = warnDuplicateKey(warnings, seen, key, lineNo)
22+ default:
23+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown key %q", key)})
24+ return remote, warnings
25+ }
26+
27+ switch key {
28+ case "enabled":
29+ on, ok := parseTrueFalse(value)
30+ if !ok {
31+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid enabled %q", value)})
32+ return remote, warnings
33+ }
34+ remote.Enabled = on
35+ case "remember":
36+ on, ok := parseTrueFalse(value)
37+ if !ok {
38+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remember %q", value)})
39+ return remote, warnings
40+ }
41+ remote.Remember = on
42+ case "hosts":
43+ hosts, hostWarnings := parseRemoteHosts(value, lineNo)
44+ warnings = append(warnings, hostWarnings...)
45+ if hosts == nil {
46+ return remote, warnings
47+ }
48+ remote.Hosts = hosts
49+ }
50+ return remote, warnings
51+}
52+
53+func parseTrueFalse(value string) (bool, bool) {
54+ switch strings.TrimSpace(value) {
55+ case "true":
56+ return true, true
57+ case "false":
58+ return false, true
59+ default:
60+ return false, false
61+ }
62+}
63+
64+func parseRemoteHosts(value string, lineNo int) ([]string, []domain.Warning) {
65+ var raw json.RawMessage
66+ if err := json.Unmarshal([]byte(value), &raw); err != nil {
67+ return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
68+ }
69+ if len(raw) == 0 || raw[0] != '[' {
70+ return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
71+ }
72+ var items []any
73+ if err := json.Unmarshal(raw, &items); err != nil {
74+ return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
75+ }
76+ out := make([]string, 0, len(items))
77+ seen := make(map[string]struct{}, len(items))
78+ var warnings []domain.Warning
79+ ok := true
80+ for _, item := range items {
81+ host, isString := item.(string)
82+ if !isString {
83+ return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
84+ }
85+ if err := domain.ValidateRemoteHostTarget(host); err != nil {
86+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remote host %q: %v", host, err)})
87+ ok = false
88+ continue
89+ }
90+ if _, dup := seen[host]; dup {
91+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate host %q", host)})
92+ ok = false
93+ continue
94+ }
95+ seen[host] = struct{}{}
96+ out = append(out, host)
97+ }
98+ if !ok {
99+ return nil, warnings
100+ }
101+ return out, nil
102+}
103+
104+// UpdateRemoteHosts creates or replaces the pinned hosts list in path's [remote]
105+// section while preserving unrelated config text. Writes use a private parent
106+// directory and same-directory temp + sync + rename + directory sync.
107+func UpdateRemoteHosts(path string, hosts []string) error {
108+ hosts = domain.UniqueRemoteHostTargets(hosts)
109+ for _, host := range hosts {
110+ if err := domain.ValidateRemoteHostTarget(host); err != nil {
111+ return err
112+ }
113+ }
114+ if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
115+ return err
116+ }
117+
118+ lockFile, err := acquireConfigLock(path)
119+ if err != nil {
120+ return err
121+ }
122+ defer func() {
123+ _ = releaseConfigLock(lockFile)
124+ }()
125+
126+ original, err := os.ReadFile(path)
127+ if err != nil && !errors.Is(err, os.ErrNotExist) {
128+ return err
129+ }
130+ missing := errors.Is(err, os.ErrNotExist)
131+
132+ hostsLine, err := formatRemoteHostsLine(hosts)
133+ if err != nil {
134+ return err
135+ }
136+
137+ var next []byte
138+ if missing || len(bytes.TrimSpace(original)) == 0 {
139+ next = []byte("[remote]\n" + hostsLine + "\n")
140+ } else {
141+ next = rewriteRemoteHosts(original, hostsLine)
142+ }
143+ return writeConfigAtomic(path, next)
144+}
145+
146+func formatRemoteHostsLine(hosts []string) (string, error) {
147+ var b strings.Builder
148+ b.WriteString("hosts = [")
149+ for i, host := range hosts {
150+ if i > 0 {
151+ b.WriteString(", ")
152+ }
153+ encoded, err := json.Marshal(host)
154+ if err != nil {
155+ return "", err
156+ }
157+ b.Write(encoded)
158+ }
159+ b.WriteByte(']')
160+ return b.String(), nil
161+}
162+
163+func rewriteRemoteHosts(original []byte, hostsLine string) []byte {
164+ lines := splitKeepNewlines(original)
165+ remoteIdx := -1
166+ hostsIdx := -1
167+ inRemote := false
168+ for i, line := range lines {
169+ trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"))
170+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
171+ continue
172+ }
173+ trimmed = stripInlineComment(trimmed)
174+ if matches := sectionPattern.FindStringSubmatch(trimmed); matches != nil {
175+ inRemote = matches[1] == "remote"
176+ if inRemote && remoteIdx < 0 {
177+ remoteIdx = i
178+ }
179+ continue
180+ }
181+ if !inRemote {
182+ continue
183+ }
184+ key, _, ok := strings.Cut(trimmed, "=")
185+ if !ok {
186+ continue
187+ }
188+ if strings.TrimSpace(key) == "hosts" {
189+ hostsIdx = i
190+ }
191+ }
192+
193+ ending := dominantLineEnding(original)
194+
195+ switch {
196+ case hostsIdx >= 0:
197+ prefix := lines[hostsIdx]
198+ nl := "\n"
199+ if strings.HasSuffix(prefix, "\r\n") {
200+ nl = "\r\n"
201+ } else if !strings.HasSuffix(prefix, "\n") {
202+ nl = ""
203+ }
204+ lines[hostsIdx] = hostsLine + nl
205+ case remoteIdx >= 0:
206+ insertAt := remoteIdx + 1
207+ for insertAt < len(lines) {
208+ trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(lines[insertAt], "\n"), "\r"))
209+ if matches := sectionPattern.FindStringSubmatch(stripInlineComment(trimmed)); matches != nil {
210+ break
211+ }
212+ insertAt++
213+ }
214+ hostsEntry := hostsLine + ending
215+ lines = append(lines[:insertAt], append([]string{hostsEntry}, lines[insertAt:]...)...)
216+ default:
217+ if len(lines) > 0 {
218+ last := lines[len(lines)-1]
219+ if last != "" && !strings.HasSuffix(last, "\n") && !strings.HasSuffix(last, "\r\n") {
220+ lines[len(lines)-1] = last + ending
221+ }
222+ }
223+ lines = append(lines, "[remote]"+ending, hostsLine+ending)
224+ }
225+
226+ var b strings.Builder
227+ for _, line := range lines {
228+ b.WriteString(line)
229+ }
230+ return []byte(b.String())
231+}
232+
233+func dominantLineEnding(data []byte) string {
234+ crlf := 0
235+ lf := 0
236+ for i := 0; i < len(data); i++ {
237+ if data[i] != '\n' {
238+ continue
239+ }
240+ if i > 0 && data[i-1] == '\r' {
241+ crlf++
242+ } else {
243+ lf++
244+ }
245+ }
246+ if crlf > lf {
247+ return "\r\n"
248+ }
249+ return "\n"
250+}
251+
252+func acquireConfigLock(path string) (*os.File, error) {
253+ file, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
254+ if err != nil {
255+ return nil, err
256+ }
257+ if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
258+ _ = file.Close()
259+ return nil, err
260+ }
261+ return file, nil
262+}
263+
264+func releaseConfigLock(file *os.File) error {
265+ if file == nil {
266+ return nil
267+ }
268+ return errors.Join(syscall.Flock(int(file.Fd()), syscall.LOCK_UN), file.Close())
269+}
270+
271+func splitKeepNewlines(data []byte) []string {
272+ if len(data) == 0 {
273+ return nil
274+ }
275+ var lines []string
276+ start := 0
277+ for i := 0; i < len(data); i++ {
278+ if data[i] == '\n' {
279+ lines = append(lines, string(data[start:i+1]))
280+ start = i + 1
281+ }
282+ }
283+ if start < len(data) {
284+ lines = append(lines, string(data[start:]))
285+ }
286+ return lines
287+}
288+
289+func writeConfigAtomic(path string, data []byte) error {
290+ dir := filepath.Dir(path)
291+ file, err := os.CreateTemp(dir, ".vev-config-*.tmp")
292+ if err != nil {
293+ return err
294+ }
295+ tmp := file.Name()
296+ committed := false
297+ defer func() {
298+ if !committed {
299+ _ = file.Close()
300+ _ = os.Remove(tmp)
301+ }
302+ }()
303+ if err := file.Chmod(0o600); err != nil {
304+ return err
305+ }
306+ if _, err := file.Write(data); err != nil {
307+ return err
308+ }
309+ if err := file.Sync(); err != nil {
310+ return err
311+ }
312+ if err := file.Close(); err != nil {
313+ return err
314+ }
315+ if err := os.Rename(tmp, path); err != nil {
316+ return err
317+ }
318+ committed = true
319+ return syncDir(dir)
320+}
321+
322+func syncDir(dir string) error {
323+ file, err := os.Open(dir)
324+ if err != nil {
325+ return err
326+ }
327+ return errors.Join(file.Sync(), file.Close())
328+}
A internal/adapters/config/remote_test.go +452 -0
@@ -0,0 +1,452 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "reflect"
7+ "strconv"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/pkg/safedir"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestParseRemote(t *testing.T) {
17+ t.Parallel()
18+
19+ tests := []struct {
20+ name string
21+ input string
22+ want domain.RemoteConfig
23+ wantWarnings []domain.Warning
24+ wantTheme domain.ThemeMode
25+ checkTheme bool
26+ wantBindingEntries []domain.ConfigEntry
27+ checkBindings bool
28+ }{
29+ {
30+ name: "defaults when section absent",
31+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
32+ },
33+ {
34+ name: "complete remote block",
35+ input: `[remote]
36+enabled = true
37+remember = true
38+hosts = ["arch", "build@mule"]
39+`,
40+ want: domain.RemoteConfig{
41+ Enabled: true,
42+ Remember: true,
43+ Hosts: []string{"arch", "build@mule"},
44+ },
45+ },
46+ {
47+ name: "enabled false",
48+ input: `[remote]
49+enabled = false
50+`,
51+ want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
52+ },
53+ {
54+ name: "remember false",
55+ input: `[remote]
56+remember = false
57+`,
58+ want: domain.RemoteConfig{Enabled: true, Remember: false, Hosts: []string{}},
59+ },
60+ {
61+ name: "legacy on off rejected and defaults retained",
62+ input: `[remote]
63+enabled = off
64+remember = on
65+`,
66+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
67+ wantWarnings: []domain.Warning{
68+ {Line: 2, Msg: `invalid enabled "off"`},
69+ {Line: 3, Msg: `invalid remember "on"`},
70+ },
71+ },
72+ {
73+ name: "empty hosts array",
74+ input: `[remote]
75+hosts = []
76+`,
77+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
78+ },
79+ {
80+ name: "duplicate keys warn and last valid wins",
81+ input: `[remote]
82+enabled = false
83+enabled = true
84+remember = false
85+remember = true
86+hosts = ["arch"]
87+hosts = ["mule"]
88+`,
89+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"mule"}},
90+ wantWarnings: []domain.Warning{
91+ {Line: 3, Msg: `duplicate key "enabled"`},
92+ {Line: 5, Msg: `duplicate key "remember"`},
93+ {Line: 7, Msg: `duplicate key "hosts"`},
94+ },
95+ },
96+ {
97+ name: "invalid booleans warn and retain defaults",
98+ input: `[remote]
99+enabled = yes
100+remember = True
101+`,
102+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
103+ wantWarnings: []domain.Warning{
104+ {Line: 2, Msg: `invalid enabled "yes"`},
105+ {Line: 3, Msg: `invalid remember "True"`},
106+ },
107+ },
108+ {
109+ name: "invalid hosts array retains default",
110+ input: `[remote]
111+hosts = [1, "arch"]
112+`,
113+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
114+ wantWarnings: []domain.Warning{
115+ {Line: 2, Msg: `invalid hosts "[1, \"arch\"]"`},
116+ },
117+ },
118+ {
119+ name: "malformed hosts array retains default",
120+ input: `[remote]
121+hosts = not-an-array
122+`,
123+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
124+ wantWarnings: []domain.Warning{
125+ {Line: 2, Msg: `invalid hosts "not-an-array"`},
126+ },
127+ },
128+ {
129+ name: "null hosts retains default",
130+ input: `[remote]
131+hosts = null
132+`,
133+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
134+ wantWarnings: []domain.Warning{
135+ {Line: 2, Msg: `invalid hosts "null"`},
136+ },
137+ },
138+ {
139+ name: "invalid and duplicate host entries retain default",
140+ input: `[remote]
141+hosts = ["arch", "arch", " bad", "ok"]
142+`,
143+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
144+ wantWarnings: []domain.Warning{
145+ {Line: 2, Msg: `duplicate host "arch"`},
146+ {Line: 2, Msg: `invalid remote host " bad": remote host target " bad" has surrounding whitespace`},
147+ },
148+ },
149+ {
150+ name: "invalid hosts assignment retains previous value",
151+ input: `[remote]
152+hosts = ["arch"]
153+hosts = ["arch", " bad", "ok"]
154+`,
155+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}},
156+ wantWarnings: []domain.Warning{
157+ {Line: 3, Msg: `duplicate key "hosts"`},
158+ {Line: 3, Msg: `invalid remote host " bad": remote host target " bad" has surrounding whitespace`},
159+ },
160+ },
161+ {
162+ name: "unknown remote key warns",
163+ input: `[remote]
164+proxy = true
165+enabled = false
166+`,
167+ want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
168+ wantWarnings: []domain.Warning{
169+ {Line: 2, Msg: `unknown key "proxy"`},
170+ },
171+ },
172+ {
173+ name: "unknown section warns and ignores its keys",
174+ input: `theme = dark
175+[other]
176+enabled = false
177+[remote]
178+enabled = false
179+`,
180+ want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
181+ wantWarnings: []domain.Warning{
182+ {Line: 2, Msg: `unknown section "other"`},
183+ },
184+ wantTheme: domain.ThemeDark,
185+ checkTheme: true,
186+ },
187+ {
188+ name: "flat keys preserved outside remote section",
189+ input: `theme = light
190+new-tab = alt+t
191+[remote]
192+hosts = ["arch"]
193+`,
194+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}},
195+ wantTheme: domain.ThemeLight,
196+ checkTheme: true,
197+ wantBindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+t"}},
198+ checkBindings: true,
199+ },
200+ }
201+
202+ for _, tt := range tests {
203+ t.Run(tt.name, func(t *testing.T) {
204+ t.Parallel()
205+
206+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
207+ require.NoError(t, err)
208+ require.Equal(t, tt.want, cfg.Remote)
209+ require.Equal(t, tt.wantWarnings, warnings)
210+ if tt.checkTheme {
211+ require.Equal(t, tt.wantTheme, cfg.Theme)
212+ }
213+ if tt.checkBindings {
214+ require.Equal(t, tt.wantBindingEntries, cfg.BindingEntries)
215+ }
216+ })
217+ }
218+}
219+
220+func TestUpdateRemoteHosts(t *testing.T) {
221+ t.Parallel()
222+
223+ privateConfigPath := func(t *testing.T) string {
224+ t.Helper()
225+ return filepath.Join(t.TempDir(), "vev", "config")
226+ }
227+
228+ t.Run("creates missing config", func(t *testing.T) {
229+ t.Parallel()
230+ path := privateConfigPath(t)
231+
232+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch", "build@mule"}))
233+
234+ data, err := os.ReadFile(path)
235+ require.NoError(t, err)
236+ require.Equal(t, "[remote]\nhosts = [\"arch\", \"build@mule\"]\n", string(data))
237+ info, err := os.Stat(path)
238+ require.NoError(t, err)
239+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
240+ })
241+
242+ t.Run("replaces existing hosts and preserves unrelated text", func(t *testing.T) {
243+ t.Parallel()
244+ path := privateConfigPath(t)
245+ require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
246+ original := "# keep me\ntheme = dark\n[remote]\nenabled = false\nhosts = [\"old\"]\nremember = false\nnew-tab = alt+t\n"
247+ require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
248+
249+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch", "arch", "mule"}))
250+
251+ data, err := os.ReadFile(path)
252+ require.NoError(t, err)
253+ got := string(data)
254+ require.Contains(t, got, "# keep me\n")
255+ require.Contains(t, got, "theme = dark\n")
256+ require.Contains(t, got, "enabled = false\n")
257+ require.Contains(t, got, "remember = false\n")
258+ require.Contains(t, got, "new-tab = alt+t\n")
259+ require.Contains(t, got, "hosts = [\"arch\", \"mule\"]\n")
260+ require.NotContains(t, got, "hosts = [\"old\"]")
261+ info, err := os.Stat(path)
262+ require.NoError(t, err)
263+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
264+ })
265+
266+ t.Run("appends remote section when missing", func(t *testing.T) {
267+ t.Parallel()
268+ path := privateConfigPath(t)
269+ require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
270+ require.NoError(t, os.WriteFile(path, []byte("theme = light\n"), 0o600))
271+
272+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
273+
274+ data, err := os.ReadFile(path)
275+ require.NoError(t, err)
276+ require.Equal(t, "theme = light\n[remote]\nhosts = [\"arch\"]\n", string(data))
277+ })
278+
279+ t.Run("appends hosts line when remote section lacks hosts", func(t *testing.T) {
280+ t.Parallel()
281+ path := privateConfigPath(t)
282+ require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
283+ require.NoError(t, os.WriteFile(path, []byte("[remote]\nenabled = true\n"), 0o600))
284+
285+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
286+
287+ data, err := os.ReadFile(path)
288+ require.NoError(t, err)
289+ require.Equal(t, "[remote]\nenabled = true\nhosts = [\"arch\"]\n", string(data))
290+ })
291+
292+ t.Run("preserves CRLF line endings when appending hosts", func(t *testing.T) {
293+ t.Parallel()
294+ path := privateConfigPath(t)
295+ require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
296+ original := "[remote]\r\nenabled = true\r\n"
297+ require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
298+
299+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
300+
301+ data, err := os.ReadFile(path)
302+ require.NoError(t, err)
303+ require.Equal(t, "[remote]\r\nenabled = true\r\nhosts = [\"arch\"]\r\n", string(data))
304+ })
305+
306+ t.Run("fails when existing path cannot be read", func(t *testing.T) {
307+ t.Parallel()
308+ path := privateConfigPath(t)
309+ require.NoError(t, os.MkdirAll(path, 0o700))
310+
311+ err := UpdateRemoteHosts(path, []string{"arch"})
312+ require.Error(t, err)
313+ })
314+
315+ t.Run("rejects invalid targets without changing existing file", func(t *testing.T) {
316+ t.Parallel()
317+ path := privateConfigPath(t)
318+ require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
319+ original := "[remote]\nhosts = [\"arch\"]\n"
320+ require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
321+
322+ invalid := []struct {
323+ name string
324+ hosts []string
325+ }{
326+ {name: "empty", hosts: []string{""}},
327+ {name: "whitespace", hosts: []string{" bad"}},
328+ {name: "control", hosts: []string{"good", "bad\x00"}},
329+ {name: "mixed with valid", hosts: []string{"arch", " "}},
330+ }
331+ for _, tt := range invalid {
332+ t.Run(tt.name, func(t *testing.T) {
333+ err := UpdateRemoteHosts(path, tt.hosts)
334+ require.Error(t, err)
335+ data, readErr := os.ReadFile(path)
336+ require.NoError(t, readErr)
337+ require.Equal(t, original, string(data))
338+ })
339+ }
340+ })
341+
342+ t.Run("rejects invalid targets before creating parent directory", func(t *testing.T) {
343+ t.Parallel()
344+ path := privateConfigPath(t)
345+
346+ err := UpdateRemoteHosts(path, []string{" "})
347+ require.Error(t, err)
348+ _, statErr := os.Stat(filepath.Dir(path))
349+ require.ErrorIs(t, statErr, os.ErrNotExist)
350+ })
351+
352+ t.Run("replaces last effective hosts assignment", func(t *testing.T) {
353+ t.Parallel()
354+ path := privateConfigPath(t)
355+ require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
356+ original := "[remote]\nhosts = [\"first\"]\nenabled = true\nhosts = [\"second\"]\n"
357+ require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
358+
359+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
360+
361+ data, err := os.ReadFile(path)
362+ require.NoError(t, err)
363+ got := string(data)
364+ require.Contains(t, got, "hosts = [\"first\"]\n")
365+ require.Contains(t, got, "hosts = [\"arch\"]\n")
366+ require.NotContains(t, got, "hosts = [\"second\"]")
367+ cfg, warnings, err := Parse(strings.NewReader(got))
368+ require.NoError(t, err)
369+ require.Equal(t, []string{"arch"}, cfg.Remote.Hosts)
370+ require.Equal(t, []domain.Warning{{Line: 4, Msg: `duplicate key "hosts"`}}, warnings)
371+ })
372+
373+ t.Run("ignores stale shared tmp permissions", func(t *testing.T) {
374+ t.Parallel()
375+ path := privateConfigPath(t)
376+ dir := filepath.Dir(path)
377+ require.NoError(t, safedir.EnsurePrivate(dir))
378+ staleTmp := path + ".tmp"
379+ require.NoError(t, os.WriteFile(staleTmp, []byte("STALE"), 0o644))
380+
381+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
382+
383+ info, err := os.Stat(path)
384+ require.NoError(t, err)
385+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
386+ stale, err := os.ReadFile(staleTmp)
387+ require.NoError(t, err)
388+ require.Equal(t, "STALE", string(stale))
389+ data, err := os.ReadFile(path)
390+ require.NoError(t, err)
391+ require.Equal(t, "[remote]\nhosts = [\"arch\"]\n", string(data))
392+ entries, err := os.ReadDir(dir)
393+ require.NoError(t, err)
394+ for _, entry := range entries {
395+ name := entry.Name()
396+ if name == "config" || name == "config.tmp" {
397+ continue
398+ }
399+ require.False(t, strings.Contains(name, ".tmp"), "leftover temp %q", name)
400+ }
401+ })
402+
403+ t.Run("concurrent writes to same path leave complete parseable result", func(t *testing.T) {
404+ t.Parallel()
405+ path := privateConfigPath(t)
406+ dir := filepath.Dir(path)
407+ require.NoError(t, safedir.EnsurePrivate(dir))
408+
409+ const n = 32
410+ lists := make([][]string, n)
411+ for i := 0; i < n; i++ {
412+ lists[i] = []string{"host-a-" + strconv.Itoa(i), "host-b-" + strconv.Itoa(i)}
413+ }
414+ errCh := make(chan error, n)
415+ for i := 0; i < n; i++ {
416+ go func(i int) {
417+ errCh <- UpdateRemoteHosts(path, lists[i])
418+ }(i)
419+ }
420+ for i := 0; i < n; i++ {
421+ require.NoError(t, <-errCh)
422+ }
423+
424+ data, err := os.ReadFile(path)
425+ require.NoError(t, err)
426+ cfg, warnings, err := Parse(strings.NewReader(string(data)))
427+ require.NoError(t, err)
428+ require.Empty(t, warnings)
429+ matched := false
430+ for _, list := range lists {
431+ if reflect.DeepEqual(cfg.Remote.Hosts, list) {
432+ matched = true
433+ break
434+ }
435+ }
436+ require.True(t, matched, "final hosts %#v must equal one complete submitted list", cfg.Remote.Hosts)
437+
438+ info, err := os.Stat(path)
439+ require.NoError(t, err)
440+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
441+
442+ entries, err := os.ReadDir(dir)
443+ require.NoError(t, err)
444+ for _, entry := range entries {
445+ name := entry.Name()
446+ if name == "config" {
447+ continue
448+ }
449+ require.False(t, strings.Contains(name, "tmp"), "leftover temp %q", name)
450+ }
451+ })
452+}
@@ -0,0 +1,452 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "reflect"
7+ "strconv"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/pkg/safedir"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestParseRemote(t *testing.T) {
17+ t.Parallel()
18+
19+ tests := []struct {
20+ name string
21+ input string
22+ want domain.RemoteConfig
23+ wantWarnings []domain.Warning
24+ wantTheme domain.ThemeMode
25+ checkTheme bool
26+ wantBindingEntries []domain.ConfigEntry
27+ checkBindings bool
28+ }{
29+ {
30+ name: "defaults when section absent",
31+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
32+ },
33+ {
34+ name: "complete remote block",
35+ input: `[remote]
36+enabled = true
37+remember = true
38+hosts = ["arch", "build@mule"]
39+`,
40+ want: domain.RemoteConfig{
41+ Enabled: true,
42+ Remember: true,
43+ Hosts: []string{"arch", "build@mule"},
44+ },
45+ },
46+ {
47+ name: "enabled false",
48+ input: `[remote]
49+enabled = false
50+`,
51+ want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
52+ },
53+ {
54+ name: "remember false",
55+ input: `[remote]
56+remember = false
57+`,
58+ want: domain.RemoteConfig{Enabled: true, Remember: false, Hosts: []string{}},
59+ },
60+ {
61+ name: "legacy on off rejected and defaults retained",
62+ input: `[remote]
63+enabled = off
64+remember = on
65+`,
66+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
67+ wantWarnings: []domain.Warning{
68+ {Line: 2, Msg: `invalid enabled "off"`},
69+ {Line: 3, Msg: `invalid remember "on"`},
70+ },
71+ },
72+ {
73+ name: "empty hosts array",
74+ input: `[remote]
75+hosts = []
76+`,
77+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
78+ },
79+ {
80+ name: "duplicate keys warn and last valid wins",
81+ input: `[remote]
82+enabled = false
83+enabled = true
84+remember = false
85+remember = true
86+hosts = ["arch"]
87+hosts = ["mule"]
88+`,
89+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"mule"}},
90+ wantWarnings: []domain.Warning{
91+ {Line: 3, Msg: `duplicate key "enabled"`},
92+ {Line: 5, Msg: `duplicate key "remember"`},
93+ {Line: 7, Msg: `duplicate key "hosts"`},
94+ },
95+ },
96+ {
97+ name: "invalid booleans warn and retain defaults",
98+ input: `[remote]
99+enabled = yes
100+remember = True
101+`,
102+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
103+ wantWarnings: []domain.Warning{
104+ {Line: 2, Msg: `invalid enabled "yes"`},
105+ {Line: 3, Msg: `invalid remember "True"`},
106+ },
107+ },
108+ {
109+ name: "invalid hosts array retains default",
110+ input: `[remote]
111+hosts = [1, "arch"]
112+`,
113+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
114+ wantWarnings: []domain.Warning{
115+ {Line: 2, Msg: `invalid hosts "[1, \"arch\"]"`},
116+ },
117+ },
118+ {
119+ name: "malformed hosts array retains default",
120+ input: `[remote]
121+hosts = not-an-array
122+`,
123+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
124+ wantWarnings: []domain.Warning{
125+ {Line: 2, Msg: `invalid hosts "not-an-array"`},
126+ },
127+ },
128+ {
129+ name: "null hosts retains default",
130+ input: `[remote]
131+hosts = null
132+`,
133+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
134+ wantWarnings: []domain.Warning{
135+ {Line: 2, Msg: `invalid hosts "null"`},
136+ },
137+ },
138+ {
139+ name: "invalid and duplicate host entries retain default",
140+ input: `[remote]
141+hosts = ["arch", "arch", " bad", "ok"]
142+`,
143+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
144+ wantWarnings: []domain.Warning{
145+ {Line: 2, Msg: `duplicate host "arch"`},
146+ {Line: 2, Msg: `invalid remote host " bad": remote host target " bad" has surrounding whitespace`},
147+ },
148+ },
149+ {
150+ name: "invalid hosts assignment retains previous value",
151+ input: `[remote]
152+hosts = ["arch"]
153+hosts = ["arch", " bad", "ok"]
154+`,
155+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}},
156+ wantWarnings: []domain.Warning{
157+ {Line: 3, Msg: `duplicate key "hosts"`},
158+ {Line: 3, Msg: `invalid remote host " bad": remote host target " bad" has surrounding whitespace`},
159+ },
160+ },
161+ {
162+ name: "unknown remote key warns",
163+ input: `[remote]
164+proxy = true
165+enabled = false
166+`,
167+ want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
168+ wantWarnings: []domain.Warning{
169+ {Line: 2, Msg: `unknown key "proxy"`},
170+ },
171+ },
172+ {
173+ name: "unknown section warns and ignores its keys",
174+ input: `theme = dark
175+[other]
176+enabled = false
177+[remote]
178+enabled = false
179+`,
180+ want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
181+ wantWarnings: []domain.Warning{
182+ {Line: 2, Msg: `unknown section "other"`},
183+ },
184+ wantTheme: domain.ThemeDark,
185+ checkTheme: true,
186+ },
187+ {
188+ name: "flat keys preserved outside remote section",
189+ input: `theme = light
190+new-tab = alt+t
191+[remote]
192+hosts = ["arch"]
193+`,
194+ want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}},
195+ wantTheme: domain.ThemeLight,
196+ checkTheme: true,
197+ wantBindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+t"}},
198+ checkBindings: true,
199+ },
200+ }
201+
202+ for _, tt := range tests {
203+ t.Run(tt.name, func(t *testing.T) {
204+ t.Parallel()
205+
206+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
207+ require.NoError(t, err)
208+ require.Equal(t, tt.want, cfg.Remote)
209+ require.Equal(t, tt.wantWarnings, warnings)
210+ if tt.checkTheme {
211+ require.Equal(t, tt.wantTheme, cfg.Theme)
212+ }
213+ if tt.checkBindings {
214+ require.Equal(t, tt.wantBindingEntries, cfg.BindingEntries)
215+ }
216+ })
217+ }
218+}
219+
220+func TestUpdateRemoteHosts(t *testing.T) {
221+ t.Parallel()
222+
223+ privateConfigPath := func(t *testing.T) string {
224+ t.Helper()
225+ return filepath.Join(t.TempDir(), "vev", "config")
226+ }
227+
228+ t.Run("creates missing config", func(t *testing.T) {
229+ t.Parallel()
230+ path := privateConfigPath(t)
231+
232+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch", "build@mule"}))
233+
234+ data, err := os.ReadFile(path)
235+ require.NoError(t, err)
236+ require.Equal(t, "[remote]\nhosts = [\"arch\", \"build@mule\"]\n", string(data))
237+ info, err := os.Stat(path)
238+ require.NoError(t, err)
239+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
240+ })
241+
242+ t.Run("replaces existing hosts and preserves unrelated text", func(t *testing.T) {
243+ t.Parallel()
244+ path := privateConfigPath(t)
245+ require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
246+ original := "# keep me\ntheme = dark\n[remote]\nenabled = false\nhosts = [\"old\"]\nremember = false\nnew-tab = alt+t\n"
247+ require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
248+
249+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch", "arch", "mule"}))
250+
251+ data, err := os.ReadFile(path)
252+ require.NoError(t, err)
253+ got := string(data)
254+ require.Contains(t, got, "# keep me\n")
255+ require.Contains(t, got, "theme = dark\n")
256+ require.Contains(t, got, "enabled = false\n")
257+ require.Contains(t, got, "remember = false\n")
258+ require.Contains(t, got, "new-tab = alt+t\n")
259+ require.Contains(t, got, "hosts = [\"arch\", \"mule\"]\n")
260+ require.NotContains(t, got, "hosts = [\"old\"]")
261+ info, err := os.Stat(path)
262+ require.NoError(t, err)
263+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
264+ })
265+
266+ t.Run("appends remote section when missing", func(t *testing.T) {
267+ t.Parallel()
268+ path := privateConfigPath(t)
269+ require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
270+ require.NoError(t, os.WriteFile(path, []byte("theme = light\n"), 0o600))
271+
272+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
273+
274+ data, err := os.ReadFile(path)
275+ require.NoError(t, err)
276+ require.Equal(t, "theme = light\n[remote]\nhosts = [\"arch\"]\n", string(data))
277+ })
278+
279+ t.Run("appends hosts line when remote section lacks hosts", func(t *testing.T) {
280+ t.Parallel()
281+ path := privateConfigPath(t)
282+ require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
283+ require.NoError(t, os.WriteFile(path, []byte("[remote]\nenabled = true\n"), 0o600))
284+
285+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
286+
287+ data, err := os.ReadFile(path)
288+ require.NoError(t, err)
289+ require.Equal(t, "[remote]\nenabled = true\nhosts = [\"arch\"]\n", string(data))
290+ })
291+
292+ t.Run("preserves CRLF line endings when appending hosts", func(t *testing.T) {
293+ t.Parallel()
294+ path := privateConfigPath(t)
295+ require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
296+ original := "[remote]\r\nenabled = true\r\n"
297+ require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
298+
299+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
300+
301+ data, err := os.ReadFile(path)
302+ require.NoError(t, err)
303+ require.Equal(t, "[remote]\r\nenabled = true\r\nhosts = [\"arch\"]\r\n", string(data))
304+ })
305+
306+ t.Run("fails when existing path cannot be read", func(t *testing.T) {
307+ t.Parallel()
308+ path := privateConfigPath(t)
309+ require.NoError(t, os.MkdirAll(path, 0o700))
310+
311+ err := UpdateRemoteHosts(path, []string{"arch"})
312+ require.Error(t, err)
313+ })
314+
315+ t.Run("rejects invalid targets without changing existing file", func(t *testing.T) {
316+ t.Parallel()
317+ path := privateConfigPath(t)
318+ require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
319+ original := "[remote]\nhosts = [\"arch\"]\n"
320+ require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
321+
322+ invalid := []struct {
323+ name string
324+ hosts []string
325+ }{
326+ {name: "empty", hosts: []string{""}},
327+ {name: "whitespace", hosts: []string{" bad"}},
328+ {name: "control", hosts: []string{"good", "bad\x00"}},
329+ {name: "mixed with valid", hosts: []string{"arch", " "}},
330+ }
331+ for _, tt := range invalid {
332+ t.Run(tt.name, func(t *testing.T) {
333+ err := UpdateRemoteHosts(path, tt.hosts)
334+ require.Error(t, err)
335+ data, readErr := os.ReadFile(path)
336+ require.NoError(t, readErr)
337+ require.Equal(t, original, string(data))
338+ })
339+ }
340+ })
341+
342+ t.Run("rejects invalid targets before creating parent directory", func(t *testing.T) {
343+ t.Parallel()
344+ path := privateConfigPath(t)
345+
346+ err := UpdateRemoteHosts(path, []string{" "})
347+ require.Error(t, err)
348+ _, statErr := os.Stat(filepath.Dir(path))
349+ require.ErrorIs(t, statErr, os.ErrNotExist)
350+ })
351+
352+ t.Run("replaces last effective hosts assignment", func(t *testing.T) {
353+ t.Parallel()
354+ path := privateConfigPath(t)
355+ require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
356+ original := "[remote]\nhosts = [\"first\"]\nenabled = true\nhosts = [\"second\"]\n"
357+ require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
358+
359+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
360+
361+ data, err := os.ReadFile(path)
362+ require.NoError(t, err)
363+ got := string(data)
364+ require.Contains(t, got, "hosts = [\"first\"]\n")
365+ require.Contains(t, got, "hosts = [\"arch\"]\n")
366+ require.NotContains(t, got, "hosts = [\"second\"]")
367+ cfg, warnings, err := Parse(strings.NewReader(got))
368+ require.NoError(t, err)
369+ require.Equal(t, []string{"arch"}, cfg.Remote.Hosts)
370+ require.Equal(t, []domain.Warning{{Line: 4, Msg: `duplicate key "hosts"`}}, warnings)
371+ })
372+
373+ t.Run("ignores stale shared tmp permissions", func(t *testing.T) {
374+ t.Parallel()
375+ path := privateConfigPath(t)
376+ dir := filepath.Dir(path)
377+ require.NoError(t, safedir.EnsurePrivate(dir))
378+ staleTmp := path + ".tmp"
379+ require.NoError(t, os.WriteFile(staleTmp, []byte("STALE"), 0o644))
380+
381+ require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
382+
383+ info, err := os.Stat(path)
384+ require.NoError(t, err)
385+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
386+ stale, err := os.ReadFile(staleTmp)
387+ require.NoError(t, err)
388+ require.Equal(t, "STALE", string(stale))
389+ data, err := os.ReadFile(path)
390+ require.NoError(t, err)
391+ require.Equal(t, "[remote]\nhosts = [\"arch\"]\n", string(data))
392+ entries, err := os.ReadDir(dir)
393+ require.NoError(t, err)
394+ for _, entry := range entries {
395+ name := entry.Name()
396+ if name == "config" || name == "config.tmp" {
397+ continue
398+ }
399+ require.False(t, strings.Contains(name, ".tmp"), "leftover temp %q", name)
400+ }
401+ })
402+
403+ t.Run("concurrent writes to same path leave complete parseable result", func(t *testing.T) {
404+ t.Parallel()
405+ path := privateConfigPath(t)
406+ dir := filepath.Dir(path)
407+ require.NoError(t, safedir.EnsurePrivate(dir))
408+
409+ const n = 32
410+ lists := make([][]string, n)
411+ for i := 0; i < n; i++ {
412+ lists[i] = []string{"host-a-" + strconv.Itoa(i), "host-b-" + strconv.Itoa(i)}
413+ }
414+ errCh := make(chan error, n)
415+ for i := 0; i < n; i++ {
416+ go func(i int) {
417+ errCh <- UpdateRemoteHosts(path, lists[i])
418+ }(i)
419+ }
420+ for i := 0; i < n; i++ {
421+ require.NoError(t, <-errCh)
422+ }
423+
424+ data, err := os.ReadFile(path)
425+ require.NoError(t, err)
426+ cfg, warnings, err := Parse(strings.NewReader(string(data)))
427+ require.NoError(t, err)
428+ require.Empty(t, warnings)
429+ matched := false
430+ for _, list := range lists {
431+ if reflect.DeepEqual(cfg.Remote.Hosts, list) {
432+ matched = true
433+ break
434+ }
435+ }
436+ require.True(t, matched, "final hosts %#v must equal one complete submitted list", cfg.Remote.Hosts)
437+
438+ info, err := os.Stat(path)
439+ require.NoError(t, err)
440+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
441+
442+ entries, err := os.ReadDir(dir)
443+ require.NoError(t, err)
444+ for _, entry := range entries {
445+ name := entry.Name()
446+ if name == "config" {
447+ continue
448+ }
449+ require.False(t, strings.Contains(name, "tmp"), "leftover temp %q", name)
450+ }
451+ })
452+}
M internal/domain/config.go +6 -0
@@ -98,6 +98,7 @@ type Config struct {
9898 Floating FloatingConfig
9999 Nav NavConfig
100100 Tabs TabsConfig
101+ Remote RemoteConfig
101102 }
102103
103104 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -135,5 +136,10 @@ func Defaults() Config {
135136 Tabs: TabsConfig{
136137 TerminalTitle: true,
137138 },
139+ Remote: RemoteConfig{
140+ Enabled: true,
141+ Remember: true,
142+ Hosts: []string{},
143+ },
138144 }
139145 }
@@ -98,6 +98,7 @@ type Config struct {
98 Floating FloatingConfig98 Floating FloatingConfig
99 Nav NavConfig99 Nav NavConfig
100 Tabs TabsConfig100 Tabs TabsConfig
101+ Remote RemoteConfig
101 }102 }
102 103
103 // Warning describes a non-fatal config problem. Parsers and reloaders should104 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -135,5 +136,10 @@ func Defaults() Config {
135 Tabs: TabsConfig{136 Tabs: TabsConfig{
136 TerminalTitle: true,137 TerminalTitle: true,
137 },138 },
139+ Remote: RemoteConfig{
140+ Enabled: true,
141+ Remember: true,
142+ Hosts: []string{},
143+ },
138 }144 }
139 }145 }
A internal/domain/remote_host.go +52 -0
@@ -0,0 +1,52 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "unicode"
7+)
8+
9+// RemoteConfig contains user-configurable remote host settings.
10+type RemoteConfig struct {
11+ Enabled bool
12+ Remember bool
13+ Hosts []string
14+}
15+
16+// RemoteHost is a merged remote host entry with its source markers.
17+type RemoteHost struct {
18+ Target string
19+ Pinned bool
20+ Learned bool
21+}
22+
23+// ValidateRemoteHostTarget rejects empty targets, surrounding/internal whitespace,
24+// and control characters. SSH target grammar is left to OpenSSH.
25+func ValidateRemoteHostTarget(target string) error {
26+ if target == "" {
27+ return fmt.Errorf("remote host target is empty")
28+ }
29+ if strings.TrimSpace(target) != target {
30+ return fmt.Errorf("remote host target %q has surrounding whitespace", target)
31+ }
32+ for _, r := range target {
33+ if unicode.IsSpace(r) || unicode.IsControl(r) {
34+ return fmt.Errorf("remote host target %q contains whitespace or control characters", target)
35+ }
36+ }
37+ return nil
38+}
39+
40+// UniqueRemoteHostTargets returns a de-duplicated copy preserving first occurrence order.
41+func UniqueRemoteHostTargets(values []string) []string {
42+ seen := make(map[string]struct{}, len(values))
43+ out := make([]string, 0, len(values))
44+ for _, value := range values {
45+ if _, ok := seen[value]; ok {
46+ continue
47+ }
48+ seen[value] = struct{}{}
49+ out = append(out, value)
50+ }
51+ return out
52+}
@@ -0,0 +1,52 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "unicode"
7+)
8+
9+// RemoteConfig contains user-configurable remote host settings.
10+type RemoteConfig struct {
11+ Enabled bool
12+ Remember bool
13+ Hosts []string
14+}
15+
16+// RemoteHost is a merged remote host entry with its source markers.
17+type RemoteHost struct {
18+ Target string
19+ Pinned bool
20+ Learned bool
21+}
22+
23+// ValidateRemoteHostTarget rejects empty targets, surrounding/internal whitespace,
24+// and control characters. SSH target grammar is left to OpenSSH.
25+func ValidateRemoteHostTarget(target string) error {
26+ if target == "" {
27+ return fmt.Errorf("remote host target is empty")
28+ }
29+ if strings.TrimSpace(target) != target {
30+ return fmt.Errorf("remote host target %q has surrounding whitespace", target)
31+ }
32+ for _, r := range target {
33+ if unicode.IsSpace(r) || unicode.IsControl(r) {
34+ return fmt.Errorf("remote host target %q contains whitespace or control characters", target)
35+ }
36+ }
37+ return nil
38+}
39+
40+// UniqueRemoteHostTargets returns a de-duplicated copy preserving first occurrence order.
41+func UniqueRemoteHostTargets(values []string) []string {
42+ seen := make(map[string]struct{}, len(values))
43+ out := make([]string, 0, len(values))
44+ for _, value := range values {
45+ if _, ok := seen[value]; ok {
46+ continue
47+ }
48+ seen[value] = struct{}{}
49+ out = append(out, value)
50+ }
51+ return out
52+}
A internal/domain/remote_host_test.go +89 -0
@@ -0,0 +1,89 @@
1+package domain
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func TestRemoteHostTargetValidation(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ target string
12+ wantErr bool
13+ }{
14+ {name: "alias", target: "arch"},
15+ {name: "user at host", target: "build@mule"},
16+ {name: "bracketed ipv6 with port", target: "user@[2001:db8::1]:2222"},
17+ {name: "empty", target: "", wantErr: true},
18+ {name: "leading whitespace", target: " arch", wantErr: true},
19+ {name: "trailing whitespace", target: "arch ", wantErr: true},
20+ {name: "internal whitespace", target: "build @mule", wantErr: true},
21+ {name: "newline", target: "arch\n", wantErr: true},
22+ {name: "tab", target: "arch\t", wantErr: true},
23+ {name: "control byte", target: "arch\x00", wantErr: true},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ err := ValidateRemoteHostTarget(tt.target)
29+ if tt.wantErr && err == nil {
30+ t.Fatalf("ValidateRemoteHostTarget(%q) = nil, want error", tt.target)
31+ }
32+ if !tt.wantErr && err != nil {
33+ t.Fatalf("ValidateRemoteHostTarget(%q) = %v, want nil", tt.target, err)
34+ }
35+ })
36+ }
37+}
38+
39+func TestRemoteHostUniqueTargets(t *testing.T) {
40+ tests := []struct {
41+ name string
42+ input []string
43+ want []string
44+ }{
45+ {
46+ name: "preserves first occurrence order",
47+ input: []string{"arch", "build@mule", "arch", "mule"},
48+ want: []string{"arch", "build@mule", "mule"},
49+ },
50+ {
51+ name: "empty input",
52+ input: nil,
53+ want: []string{},
54+ },
55+ {
56+ name: "no duplicates",
57+ input: []string{"alpha", "beta"},
58+ want: []string{"alpha", "beta"},
59+ },
60+ }
61+
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ got := UniqueRemoteHostTargets(tt.input)
65+ if got == nil {
66+ t.Fatalf("UniqueRemoteHostTargets(%v) = nil, want non-nil slice", tt.input)
67+ }
68+ if !slices.Equal(got, tt.want) {
69+ t.Fatalf("UniqueRemoteHostTargets(%v) = %v, want %v", tt.input, got, tt.want)
70+ }
71+ })
72+ }
73+}
74+
75+func TestRemoteHostConfigDefaults(t *testing.T) {
76+ cfg := Defaults()
77+ if !cfg.Remote.Enabled {
78+ t.Fatal("Defaults().Remote.Enabled = false, want true")
79+ }
80+ if !cfg.Remote.Remember {
81+ t.Fatal("Defaults().Remote.Remember = false, want true")
82+ }
83+ if cfg.Remote.Hosts == nil {
84+ t.Fatal("Defaults().Remote.Hosts = nil, want empty slice")
85+ }
86+ if len(cfg.Remote.Hosts) != 0 {
87+ t.Fatalf("Defaults().Remote.Hosts = %v, want empty", cfg.Remote.Hosts)
88+ }
89+}
@@ -0,0 +1,89 @@
1+package domain
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func TestRemoteHostTargetValidation(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ target string
12+ wantErr bool
13+ }{
14+ {name: "alias", target: "arch"},
15+ {name: "user at host", target: "build@mule"},
16+ {name: "bracketed ipv6 with port", target: "user@[2001:db8::1]:2222"},
17+ {name: "empty", target: "", wantErr: true},
18+ {name: "leading whitespace", target: " arch", wantErr: true},
19+ {name: "trailing whitespace", target: "arch ", wantErr: true},
20+ {name: "internal whitespace", target: "build @mule", wantErr: true},
21+ {name: "newline", target: "arch\n", wantErr: true},
22+ {name: "tab", target: "arch\t", wantErr: true},
23+ {name: "control byte", target: "arch\x00", wantErr: true},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ err := ValidateRemoteHostTarget(tt.target)
29+ if tt.wantErr && err == nil {
30+ t.Fatalf("ValidateRemoteHostTarget(%q) = nil, want error", tt.target)
31+ }
32+ if !tt.wantErr && err != nil {
33+ t.Fatalf("ValidateRemoteHostTarget(%q) = %v, want nil", tt.target, err)
34+ }
35+ })
36+ }
37+}
38+
39+func TestRemoteHostUniqueTargets(t *testing.T) {
40+ tests := []struct {
41+ name string
42+ input []string
43+ want []string
44+ }{
45+ {
46+ name: "preserves first occurrence order",
47+ input: []string{"arch", "build@mule", "arch", "mule"},
48+ want: []string{"arch", "build@mule", "mule"},
49+ },
50+ {
51+ name: "empty input",
52+ input: nil,
53+ want: []string{},
54+ },
55+ {
56+ name: "no duplicates",
57+ input: []string{"alpha", "beta"},
58+ want: []string{"alpha", "beta"},
59+ },
60+ }
61+
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ got := UniqueRemoteHostTargets(tt.input)
65+ if got == nil {
66+ t.Fatalf("UniqueRemoteHostTargets(%v) = nil, want non-nil slice", tt.input)
67+ }
68+ if !slices.Equal(got, tt.want) {
69+ t.Fatalf("UniqueRemoteHostTargets(%v) = %v, want %v", tt.input, got, tt.want)
70+ }
71+ })
72+ }
73+}
74+
75+func TestRemoteHostConfigDefaults(t *testing.T) {
76+ cfg := Defaults()
77+ if !cfg.Remote.Enabled {
78+ t.Fatal("Defaults().Remote.Enabled = false, want true")
79+ }
80+ if !cfg.Remote.Remember {
81+ t.Fatal("Defaults().Remote.Remember = false, want true")
82+ }
83+ if cfg.Remote.Hosts == nil {
84+ t.Fatal("Defaults().Remote.Hosts = nil, want empty slice")
85+ }
86+ if len(cfg.Remote.Hosts) != 0 {
87+ t.Fatalf("Defaults().Remote.Hosts = %v, want empty", cfg.Remote.Hosts)
88+ }
89+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close