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
116 changes: 116 additions & 0 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
name: CI

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

on:
push:
branches: ["*"]
pull_request:
branches: ["*"]

permissions:
contents: read

jobs:
lint:
name: Static Code Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Go
id: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true

- name: Check Formatting (gofmt)
run: |
unformatted=$(gofmt -s -l .)
if [ -n "$unformatted" ]; then
echo "The following files are not formatted correctly:"
echo "$unformatted"
echo "Please run 'gofmt -w .' locally to fix them."
exit 1
fi

- name: Cache golangci-lint binary
id: cache-golangci-lint
uses: actions/cache@v4
with:
path: ~/go/bin/golangci-lint
key: ${{ runner.os }}-golangci-lint-latest-${{ steps.setup-go.outputs.go-version }}

- name: Install golangci-lint
if: steps.cache-golangci-lint.outputs.cache-hit != 'true'
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest

- name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --config=.golangci.yml

vulncheck:
name: Security Vulnerability Scan
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Go
id: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true

- name: Cache govulncheck binary
id: cache-govulncheck
uses: actions/cache@v4
with:
path: ~/go/bin/govulncheck
key: ${{ runner.os }}-govulncheck-latest-${{ steps.setup-go.outputs.go-version }}

- name: Install govulncheck
if: steps.cache-govulncheck.outputs.cache-hit != 'true'
run: go install golang.org/x/vuln/cmd/govulncheck@latest

- name: Run govulncheck
run: govulncheck ./...

test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true

- name: Download Go dependencies
run: go mod download

- name: Build Project
run: go build -v ./...

- name: Run Unit Tests
run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...

- name: Generate Coverage Summary
if: always()
run: |
if [ -f coverage.out ]; then
echo "### Test Coverage Summary" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.out >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
else
echo "### Test Coverage Summary" >> $GITHUB_STEP_SUMMARY
echo "No coverage file found." >> $GITHUB_STEP_SUMMARY
fi
35 changes: 35 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Release

on:
push:
tags:
- "v*"

permissions:
contents: read

jobs:
goreleaser:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: v2.7.0
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36 changes: 36 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
version: 2
project_name: tusshi

before:
hooks:
- go mod tidy

builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
main: ./cmd/tusshi/main.go
binary: tusshi

archives:
- format: tar.gz
# use zip for windows archives
format_overrides:
- goos: windows
format: zip

checksum:
name_template: "checksums.txt"

changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
2 changes: 1 addition & 1 deletion pkg/config/config_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ func (m *Manager) DeleteConfigFile(targetPath string) error {
for _, astHost := range cfg.Hosts {
val := reflect.ValueOf(astHost)
isImplicit := false
if val.Kind() == reflect.Ptr && !val.IsNil() {
if val.Kind() == reflect.Pointer && !val.IsNil() {
elem := val.Elem()
implicitField := elem.FieldByName("implicit")
if implicitField.IsValid() && implicitField.Kind() == reflect.Bool && implicitField.Bool() {
Expand Down
12 changes: 6 additions & 6 deletions pkg/config/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func (m *Manager) UpdateHost(originalAlias string, h *Host) error {
for _, astHost := range decoded.Hosts {
val := reflect.ValueOf(astHost)
isImplicit := false
if val.Kind() == reflect.Ptr && !val.IsNil() {
if val.Kind() == reflect.Pointer && !val.IsNil() {
elem := val.Elem()
implicitField := elem.FieldByName("implicit")
if implicitField.IsValid() && implicitField.Kind() == reflect.Bool && implicitField.Bool() {
Expand Down Expand Up @@ -239,20 +239,20 @@ func buildHostString(h *Host) string {
var sb strings.Builder
fmt.Fprintf(&sb, "Host %s\n", h.Alias)
if h.Name != "" {
fmt.Fprintf(&sb, " HostName %s\n", h.Name)
fmt.Fprintf(&sb, " %s %s\n", keyHostName, h.Name)
}
if h.User != "" {
fmt.Fprintf(&sb, " User %s\n", h.User)
fmt.Fprintf(&sb, " %s %s\n", keyUser, h.User)
}
if h.Port != "" {
fmt.Fprintf(&sb, " Port %s\n", h.Port)
fmt.Fprintf(&sb, " %s %s\n", keyPort, h.Port)
}
if h.IdentityFile != "" {
fmt.Fprintf(&sb, " IdentityFile %s\n", h.IdentityFile)
fmt.Fprintf(&sb, " %s %s\n", keyIdentityFile, h.IdentityFile)
}

for k, v := range h.Properties {
if k != "HostName" && k != "User" && k != "Port" && k != "IdentityFile" && v != "" {
if k != keyHostName && k != keyUser && k != keyPort && k != keyIdentityFile && v != "" {
fmt.Fprintf(&sb, " %s %s\n", k, v)
}
}
Expand Down
10 changes: 10 additions & 0 deletions pkg/config/keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package config

const (
keyHostName = "HostName"
keyUser = "User"
keyPort = "Port"
keyIdentityFile = "IdentityFile"
keyForwardAgent = "ForwardAgent"
keyProxyJump = "ProxyJump"
)
20 changes: 10 additions & 10 deletions pkg/config/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func (m *Manager) GetHosts() []*Host {
for _, astHost := range cfg.Hosts {
// Skip the implicit default "Host *" block added by the parser
val := reflect.ValueOf(astHost)
if val.Kind() == reflect.Ptr && !val.IsNil() {
if val.Kind() == reflect.Pointer && !val.IsNil() {
elem := val.Elem()
implicitField := elem.FieldByName("implicit")
if implicitField.IsValid() && implicitField.Kind() == reflect.Bool && implicitField.Bool() {
Expand Down Expand Up @@ -106,7 +106,7 @@ func (m *Manager) GetHosts() []*Host {

// Inject inherited properties from matching wildcard blocks.
if !isWildcard && globalConfig != nil {
for _, key := range []string{"HostName", "User", "Port", "IdentityFile", "ForwardAgent", "ProxyJump"} {
for _, key := range []string{keyHostName, keyUser, keyPort, keyIdentityFile, keyForwardAgent, keyProxyJump} {
if _, explicit := h.Properties[key]; !explicit {
if resolvedVal, err := globalConfig.Get(alias, key); err == nil && resolvedVal != "" {
h.ResolvedProperties[key] = resolvedVal
Expand All @@ -116,17 +116,17 @@ func (m *Manager) GetHosts() []*Host {
}

// Update resolved shortcuts.
if h.Name == "" && h.ResolvedProperties["HostName"] != "" {
h.Name = h.ResolvedProperties["HostName"]
if h.Name == "" && h.ResolvedProperties[keyHostName] != "" {
h.Name = h.ResolvedProperties[keyHostName]
}
if h.User == "" && h.ResolvedProperties["User"] != "" {
h.User = h.ResolvedProperties["User"]
if h.User == "" && h.ResolvedProperties[keyUser] != "" {
h.User = h.ResolvedProperties[keyUser]
}
if h.Port == "" && h.ResolvedProperties["Port"] != "" {
h.Port = h.ResolvedProperties["Port"]
if h.Port == "" && h.ResolvedProperties[keyPort] != "" {
h.Port = h.ResolvedProperties[keyPort]
}
if h.IdentityFile == "" && h.ResolvedProperties["IdentityFile"] != "" {
h.IdentityFile = h.ResolvedProperties["IdentityFile"]
if h.IdentityFile == "" && h.ResolvedProperties[keyIdentityFile] != "" {
h.IdentityFile = h.ResolvedProperties[keyIdentityFile]
}

hosts = append(hosts, h)
Expand Down
14 changes: 7 additions & 7 deletions pkg/config/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,15 @@ Host 10.200.1.46
assert.Equal(t, "~/.ssh/keys/work_rsa", prodHost.IdentityFile)
// Verify prodHost inherited port 2222 from wildcard Host *
assert.Equal(t, "2222", prodHost.ResolvedProperties["Port"])
assert.Equal(t, "yes", prodHost.ResolvedProperties["ForwardAgent"])
assert.Equal(t, "yes", prodHost.ResolvedProperties[keyForwardAgent])

// Verify dbHost does not have alias (its alias is the IP itself)
assert.NotNil(t, dbHost)
assert.Equal(t, "10.200.1.46", dbHost.Alias)
// dbHost should inherit User, Port, and ForwardAgent from wildcard
assert.Equal(t, "default_user", dbHost.User)
assert.Equal(t, "2222", dbHost.Port)
assert.Equal(t, "yes", dbHost.ResolvedProperties["ForwardAgent"])
assert.Equal(t, "yes", dbHost.ResolvedProperties[keyForwardAgent])
}

// TestManagerIncludes tests glob inclusion and recursive parsing.
Expand Down Expand Up @@ -130,7 +130,7 @@ Host my-host
User: "admin",
Port: "22",
Properties: map[string]string{
"ForwardAgent": "yes",
keyForwardAgent: "yes",
},
}
err = mgr.AddHost(primaryPath, newHost)
Expand All @@ -153,7 +153,7 @@ Host my-host
assert.Equal(t, "192.168.1.10", addedHost.Name)
assert.Equal(t, "admin", addedHost.User)
assert.Equal(t, "22", addedHost.Port)
assert.Equal(t, "yes", addedHost.Properties["ForwardAgent"])
assert.Equal(t, "yes", addedHost.Properties[keyForwardAgent])

// 2. Update host
updatedHost := &Host{
Expand All @@ -162,8 +162,8 @@ Host my-host
User: "root",
Port: "222",
Properties: map[string]string{
"ForwardAgent": "no",
"ProxyJump": "jump-box",
keyForwardAgent: "no",
"ProxyJump": "jump-box",
},
}
err = mgr2.UpdateHost("added-host", updatedHost)
Expand All @@ -186,7 +186,7 @@ Host my-host
assert.Equal(t, "192.168.1.15", foundUpdated.Name)
assert.Equal(t, "root", foundUpdated.User)
assert.Equal(t, "222", foundUpdated.Port)
assert.Equal(t, "no", foundUpdated.Properties["ForwardAgent"])
assert.Equal(t, "no", foundUpdated.Properties[keyForwardAgent])
assert.Equal(t, "jump-box", foundUpdated.Properties["ProxyJump"])

// 3. Delete host
Expand Down
9 changes: 9 additions & 0 deletions pkg/tui/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package tui

const (
actionAdd = "add"
actionEdit = "edit"
tabAll = "All"
keyEsc = "esc"
keyEnter = "enter"
)
8 changes: 0 additions & 8 deletions pkg/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,6 @@ const (
ModeCommand
)

const (
actionAdd = "add"
actionEdit = "edit"
tabAll = "All"
keyEsc = "esc"
keyEnter = "enter"
)

// Model holds the state machine parameters for the Bubble Tea application loop.
type Model struct {
Manager *config.Manager
Expand Down
Loading