Skip to content

Commit 0f2b25f

Browse files
committed
Merge branch 'development'
2 parents c325d1c + c90d902 commit 0f2b25f

File tree

16 files changed

+528
-143
lines changed

16 files changed

+528
-143
lines changed

Makefile

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
APP := managedssh
22
LOCAL_BIN := $(HOME)/.local/bin
33

4-
.PHONY: help build run test fmt tidy install clean
4+
.PHONY: help build run test test-race vet lint fmt tidy install uninstall clean
55

66
help:
77
@printf "Available targets:\n"
88
@printf " make build Build the binary\n"
99
@printf " make run Run the app\n"
1010
@printf " make test Run tests\n"
11+
@printf " make test-race Run tests with race detector\n"
12+
@printf " make vet Run go vet\n"
13+
@printf " make lint Run golangci-lint (if installed)\n"
1114
@printf " make fmt Format Go files\n"
1215
@printf " make tidy Tidy Go modules\n"
1316
@printf " make install Install to GOBIN/GOPATH/bin, or ~/.local/bin fallback\n"
17+
@printf " make uninstall Remove installed binary\n"
1418
@printf " make clean Remove build artifacts and Go caches\n"
1519

1620
build:
@@ -22,6 +26,19 @@ run:
2226
test:
2327
go test ./...
2428

29+
test-race:
30+
go test -race ./...
31+
32+
vet:
33+
go vet ./...
34+
35+
lint:
36+
@if command -v golangci-lint >/dev/null 2>&1; then \
37+
golangci-lint run; \
38+
else \
39+
printf "golangci-lint not installed; skipping lint target.\n"; \
40+
fi
41+
2542
fmt:
2643
gofmt -w $$(find . -name '*.go' -not -path './vendor/*')
2744

@@ -42,6 +59,21 @@ install:
4259
esac; \
4360
fi
4461

62+
uninstall:
63+
@if [ -n "$$GOBIN" ]; then \
64+
target="$$GOBIN/$(APP)"; \
65+
elif [ -n "$$GOPATH" ]; then \
66+
target="$${GOPATH%%:*}/bin/$(APP)"; \
67+
else \
68+
target="$(LOCAL_BIN)/$(APP)"; \
69+
fi; \
70+
if [ -f "$$target" ]; then \
71+
rm -f "$$target"; \
72+
printf "Removed $$target\n"; \
73+
else \
74+
printf "No installed binary found at $$target\n"; \
75+
fi
76+
4577
clean:
4678
rm -f $(APP)
4779
rm -f *.test *.out coverage.out

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ ManagedSSH is a terminal-first SSH connection manager built with Go, Cobra, and
1919
- Encrypted vault using Argon2 + AES-GCM for stored credentials.
2020
- Host key verification with known_hosts integration and trust confirmation.
2121
- Host profiles with:
22-
- Alias, hostname, port, group, tags
22+
- Alias, hostname, port, SSH timeout, group, tags
2323
- Multiple user accounts per host
2424
- Per-user password or SSH key authentication
2525
- Support for SSH key path or inline encrypted key data.
@@ -88,15 +88,35 @@ make install
8888
Common commands:
8989

9090
```bash
91+
make help
9192
make build
9293
make run
9394
make test
95+
make test-race
96+
make vet
97+
make lint
9498
make fmt
9599
make tidy
96100
make install
101+
make uninstall
97102
make clean
98103
```
99104

105+
Target summary:
106+
107+
- make help: show available targets
108+
- make build: build the binary as managedssh in the project root
109+
- make run: run the app with go run
110+
- make test: run all tests
111+
- make test-race: run all tests with the race detector
112+
- make vet: run go vet checks
113+
- make lint: run golangci-lint if installed, otherwise skip with a message
114+
- make fmt: format Go files (excluding vendor)
115+
- make tidy: run go mod tidy
116+
- make install: install to GOBIN or GOPATH/bin, with ~/.local/bin fallback
117+
- make uninstall: remove installed binary from GOBIN, GOPATH/bin, or ~/.local/bin fallback
118+
- make clean: remove the local built binary
119+
100120
Install behavior:
101121

102122
- `make install` uses `go install .` when `GOBIN` or `GOPATH` is set.

cmd/root_test.go

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,27 @@ import (
66
"testing"
77
)
88

9-
func TestRootCmdRejectsPositionalArgs(t *testing.T) {
10-
err := rootCmd.Args(rootCmd, []string{"extra"})
11-
if err == nil {
12-
t.Fatal("expected positional args to be rejected")
13-
}
14-
}
15-
16-
func TestRootCmdHelpIncludesInterfaceControls(t *testing.T) {
9+
func TestHelpIncludesInterfaceControls(t *testing.T) {
1710
var out bytes.Buffer
1811
rootCmd.SetOut(&out)
1912
rootCmd.SetErr(&out)
2013
rootCmd.SetArgs([]string{"--help"})
2114

2215
if err := rootCmd.Execute(); err != nil {
23-
t.Fatalf("expected help to render without error: %v", err)
16+
t.Fatalf("execute help command: %v", err)
2417
}
2518

2619
help := out.String()
2720
if !strings.Contains(help, "Interface Controls:") {
28-
t.Fatal("expected help output to include interface controls header")
21+
t.Fatal("expected help output to include interface controls section")
2922
}
3023
if !strings.Contains(help, "change master key") {
3124
t.Fatal("expected help output to include at least one dashboard control")
3225
}
3326
}
27+
28+
func TestRootRejectsPositionalArguments(t *testing.T) {
29+
if err := rootCmd.Args(rootCmd, []string{"extra"}); err == nil {
30+
t.Fatal("expected positional arguments to be rejected")
31+
}
32+
}

internal/backup/transfer.go

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,9 @@ func Export(path string) error {
6969
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
7070
return fmt.Errorf("creating export directory: %w", err)
7171
}
72-
73-
tmp := path + ".tmp"
74-
if err := os.WriteFile(tmp, out, 0600); err != nil {
72+
if err := atomicWrite(path, out, 0600); err != nil {
7573
return fmt.Errorf("writing export bundle: %w", err)
7674
}
77-
if err := os.Rename(tmp, path); err != nil {
78-
return fmt.Errorf("finalizing export bundle: %w", err)
79-
}
8075
return nil
8176
}
8277

@@ -154,9 +149,42 @@ func loadBundle(path string) (*bundle, error) {
154149
}
155150

156151
func atomicWrite(path string, data []byte, perm os.FileMode) error {
157-
tmp := path + ".tmp"
158-
if err := os.WriteFile(tmp, data, perm); err != nil {
152+
tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*")
153+
if err != nil {
154+
return err
155+
}
156+
tmpPath := tmpFile.Name()
157+
closed := false
158+
defer func() {
159+
if !closed {
160+
_ = tmpFile.Close()
161+
}
162+
_ = os.Remove(tmpPath)
163+
}()
164+
165+
if err := tmpFile.Chmod(perm); err != nil {
166+
return err
167+
}
168+
if _, err := tmpFile.Write(data); err != nil {
169+
return err
170+
}
171+
if err := tmpFile.Sync(); err != nil {
172+
return err
173+
}
174+
if err := tmpFile.Close(); err != nil {
175+
return err
176+
}
177+
closed = true
178+
179+
if err := os.Rename(tmpPath, path); err != nil {
159180
return err
160181
}
161-
return os.Rename(tmp, path)
182+
183+
dir, err := os.Open(filepath.Dir(path))
184+
if err == nil {
185+
_ = dir.Sync()
186+
_ = dir.Close()
187+
}
188+
189+
return nil
162190
}

internal/host/store.go

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type Host struct {
2121
Alias string `json:"alias"`
2222
Hostname string `json:"hostname"`
2323
Port int `json:"port"`
24+
TimeoutSec int `json:"timeout_sec,omitempty"`
2425
Group string `json:"group,omitempty"`
2526
Tags []string `json:"tags,omitempty"`
2627
DefaultUser string `json:"default_user,omitempty"`
@@ -92,11 +93,7 @@ func (s *Store) Save() error {
9293
if err != nil {
9394
return err
9495
}
95-
tmp := s.path + ".tmp"
96-
if err := os.WriteFile(tmp, data, 0600); err != nil {
97-
return err
98-
}
99-
return os.Rename(tmp, s.path)
96+
return atomicWrite(s.path, data, 0600)
10097
}
10198

10299
func (s *Store) Add(h Host) error {
@@ -117,6 +114,9 @@ func (s *Store) Add(h Host) error {
117114
if h.Port == 0 {
118115
h.Port = 22
119116
}
117+
if h.TimeoutSec == 0 {
118+
h.TimeoutSec = 10
119+
}
120120
h.Normalize()
121121
s.Hosts = append(s.Hosts, h)
122122
return s.Save()
@@ -199,6 +199,9 @@ func (h *Host) Normalize() {
199199
if h.Port == 0 {
200200
h.Port = 22
201201
}
202+
if h.TimeoutSec <= 0 {
203+
h.TimeoutSec = 10
204+
}
202205

203206
defaultAuth := normalizeAuthType(h.DefaultAuthType)
204207
if defaultAuth == "" {
@@ -403,3 +406,44 @@ func cloneBytes(src []byte) []byte {
403406
copy(out, src)
404407
return out
405408
}
409+
410+
func atomicWrite(path string, data []byte, perm os.FileMode) error {
411+
tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*")
412+
if err != nil {
413+
return err
414+
}
415+
tmpPath := tmpFile.Name()
416+
closed := false
417+
defer func() {
418+
if !closed {
419+
_ = tmpFile.Close()
420+
}
421+
_ = os.Remove(tmpPath)
422+
}()
423+
424+
if err := tmpFile.Chmod(perm); err != nil {
425+
return err
426+
}
427+
if _, err := tmpFile.Write(data); err != nil {
428+
return err
429+
}
430+
if err := tmpFile.Sync(); err != nil {
431+
return err
432+
}
433+
if err := tmpFile.Close(); err != nil {
434+
return err
435+
}
436+
closed = true
437+
438+
if err := os.Rename(tmpPath, path); err != nil {
439+
return err
440+
}
441+
442+
dir, err := os.Open(filepath.Dir(path))
443+
if err == nil {
444+
_ = dir.Sync()
445+
_ = dir.Close()
446+
}
447+
448+
return nil
449+
}

internal/host/store_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,21 @@ func TestStoreUpdateRejectsDuplicateIdentity(t *testing.T) {
143143
t.Fatalf("expected ErrDuplicateAlias, got %v", err)
144144
}
145145
}
146+
147+
func TestHostNormalizeTimeoutDefaultsAndCustom(t *testing.T) {
148+
t.Run("defaults to ten seconds", func(t *testing.T) {
149+
h := Host{}
150+
h.Normalize()
151+
if h.TimeoutSec != 10 {
152+
t.Fatalf("expected default timeout 10, got %d", h.TimeoutSec)
153+
}
154+
})
155+
156+
t.Run("preserves positive timeout", func(t *testing.T) {
157+
h := Host{TimeoutSec: 17}
158+
h.Normalize()
159+
if h.TimeoutSec != 17 {
160+
t.Fatalf("expected timeout 17, got %d", h.TimeoutSec)
161+
}
162+
})
163+
}

0 commit comments

Comments
 (0)