Skip to content

Commit a6172da

Browse files
committed
feat: add greywall update command with install-method-aware updating
1 parent 505b5d4 commit a6172da

3 files changed

Lines changed: 270 additions & 0 deletions

File tree

‎cmd/greywall/main.go‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package main
44
import (
55
"encoding/json"
66
"fmt"
7+
"io"
78
"net/url"
89
"os"
910
"os/exec"
@@ -141,6 +142,7 @@ Configuration file format:
141142
rootCmd.AddCommand(newProfilesCmd())
142143
rootCmd.AddCommand(newCheckCmd())
143144
rootCmd.AddCommand(newSetupCmd())
145+
rootCmd.AddCommand(newUpdateCmd())
144146

145147
if err := rootCmd.Execute(); err != nil {
146148
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -878,6 +880,162 @@ func runSetup(_ *cobra.Command, _ []string) error {
878880
})
879881
}
880882

883+
// newUpdateCmd creates the update subcommand for updating greywall and greyproxy.
884+
func newUpdateCmd() *cobra.Command {
885+
cmd := &cobra.Command{
886+
Use: "update",
887+
Short: "Update greywall and greyproxy to the latest release",
888+
Long: `Updates greywall and greyproxy by downloading the latest release binaries.
889+
890+
The update method depends on how greywall was originally installed:
891+
- Installed via install.sh → downloads and replaces binaries automatically
892+
- Installed via Homebrew → prints the brew upgrade command to run
893+
- Installed from source → prints manual instructions
894+
895+
Examples:
896+
greywall update # update to latest stable
897+
greywall update --beta # update to latest beta`,
898+
Args: cobra.NoArgs,
899+
RunE: runUpdate,
900+
}
901+
cmd.Flags().Bool("beta", false, "Update to the latest beta (pre-release) version")
902+
return cmd
903+
}
904+
905+
// installMethod represents how greywall was originally installed.
906+
type installMethod int
907+
908+
const (
909+
installMethodScript installMethod = iota // install.sh → ~/.local/bin
910+
installMethodBrew // Homebrew
911+
installMethodSource // built from source / unknown
912+
)
913+
914+
// detectInstallMethod returns how the currently running greywall was installed.
915+
func detectInstallMethod(selfPath string) installMethod {
916+
if proxy.IsBrewManaged(selfPath) {
917+
return installMethodBrew
918+
}
919+
home, err := os.UserHomeDir()
920+
if err == nil {
921+
localBin := filepath.Join(home, ".local", "bin")
922+
if filepath.Dir(selfPath) == localBin {
923+
return installMethodScript
924+
}
925+
}
926+
return installMethodSource
927+
}
928+
929+
func runUpdate(cmd *cobra.Command, _ []string) error {
930+
beta, _ := cmd.Flags().GetBool("beta")
931+
932+
// Fetch latest tags for both tools
933+
greyproxyTag, err := proxy.CheckLatestTag(beta)
934+
if err != nil {
935+
return fmt.Errorf("failed to fetch latest greyproxy tag: %w", err)
936+
}
937+
greywallTag, err := proxy.CheckLatestTagFor("GreyhavenHQ", "greywall", beta)
938+
if err != nil {
939+
return fmt.Errorf("failed to fetch latest greywall tag: %w", err)
940+
}
941+
942+
channel := "stable"
943+
if beta {
944+
channel = "beta"
945+
}
946+
fmt.Printf("Latest %s: greywall %s, greyproxy %s\n\n", channel, greywallTag, greyproxyTag)
947+
948+
// Detect how greywall was installed (resolve symlinks first)
949+
selfPath, err := os.Executable()
950+
if err != nil {
951+
selfPath = ""
952+
} else if resolved, err := filepath.EvalSymlinks(selfPath); err == nil {
953+
selfPath = resolved
954+
}
955+
956+
switch detectInstallMethod(selfPath) {
957+
case installMethodBrew:
958+
fmt.Printf("greywall is managed by Homebrew. To update both tools, run:\n")
959+
fmt.Printf(" brew upgrade greywall greyproxy\n")
960+
return nil
961+
962+
case installMethodSource:
963+
fmt.Printf("greywall was installed from source. To update manually:\n\n")
964+
fmt.Printf(" greywall:\n")
965+
fmt.Printf(" git clone --branch %s https://github.com/GreyhavenHQ/greywall.git\n", greywallTag)
966+
fmt.Printf(" cd greywall && make build && cp greywall ~/.local/bin/\n\n")
967+
fmt.Printf(" greyproxy:\n")
968+
fmt.Printf(" git clone --branch %s https://github.com/GreyhavenHQ/greyproxy.git\n", greyproxyTag)
969+
fmt.Printf(" cd greyproxy && go build -o greyproxy ./cmd/greyproxy && greyproxy install --force\n")
970+
return nil // exit 0 — not broken, just can't automate
971+
972+
default: // installMethodScript
973+
// Update greyproxy via binary download
974+
fmt.Println("==> Updating greyproxy...")
975+
if err := proxy.Install(proxy.InstallOptions{
976+
Output: os.Stderr,
977+
Tag: greyproxyTag,
978+
}); err != nil {
979+
return fmt.Errorf("failed to update greyproxy: %w", err)
980+
}
981+
982+
// Update greywall via binary download
983+
fmt.Println("\n==> Updating greywall...")
984+
if err := updateSelf(greywallTag, selfPath, os.Stderr); err != nil {
985+
return fmt.Errorf("failed to update greywall: %w", err)
986+
}
987+
fmt.Printf("\ngreywall and greyproxy updated to %s channel.\n", channel)
988+
return nil
989+
}
990+
}
991+
992+
// updateSelf downloads the greywall release binary and replaces the running binary at execPath.
993+
func updateSelf(tag, execPath string, output io.Writer) error {
994+
_, _ = fmt.Fprintf(output, "Downloading greywall %s...\n", tag)
995+
996+
binPath, cleanup, err := proxy.DownloadGreywallBinary(tag)
997+
if err != nil {
998+
return err
999+
}
1000+
defer cleanup()
1001+
1002+
_, _ = fmt.Fprintf(output, "Replacing %s...\n", execPath)
1003+
if err := os.Rename(binPath, execPath); err != nil {
1004+
// Rename across filesystems fails; copy instead
1005+
if err2 := copyFileTo(binPath, execPath); err2 != nil {
1006+
return fmt.Errorf("failed to replace binary: %w (copy also failed: %v)", err, err2)
1007+
}
1008+
}
1009+
1010+
_, _ = fmt.Fprintf(output, "greywall updated to %s\n", tag)
1011+
return nil
1012+
}
1013+
1014+
// copyFileTo copies src to dst atomically (write to temp, then rename).
1015+
func copyFileTo(src, dst string) error {
1016+
in, err := os.Open(src) //nolint:gosec // src is a path we control
1017+
if err != nil {
1018+
return err
1019+
}
1020+
defer func() { _ = in.Close() }()
1021+
1022+
tmpDst := dst + ".new"
1023+
out, err := os.OpenFile(tmpDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) //nolint:gosec
1024+
if err != nil {
1025+
return err
1026+
}
1027+
defer func() { _ = os.Remove(tmpDst) }()
1028+
1029+
if _, err := io.Copy(out, in); err != nil {
1030+
_ = out.Close()
1031+
return err
1032+
}
1033+
if err := out.Close(); err != nil {
1034+
return err
1035+
}
1036+
return os.Rename(tmpDst, dst)
1037+
}
1038+
8811039
// newCompletionCmd creates the completion subcommand for shell completions.
8821040
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
8831041
cmd := &cobra.Command{

‎cmd/greywall/main_test.go‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestCopyFileTo(t *testing.T) {
10+
t.Run("copies content and sets executable bit", func(t *testing.T) {
11+
dir := t.TempDir()
12+
src := filepath.Join(dir, "src")
13+
dst := filepath.Join(dir, "dst")
14+
15+
content := []byte("hello binary")
16+
if err := os.WriteFile(src, content, 0o600); err != nil {
17+
t.Fatal(err)
18+
}
19+
20+
if err := copyFileTo(src, dst); err != nil {
21+
t.Fatalf("copyFileTo: %v", err)
22+
}
23+
24+
got, err := os.ReadFile(dst) //nolint:gosec // dst is a temp file path from t.TempDir()
25+
if err != nil {
26+
t.Fatalf("reading dst: %v", err)
27+
}
28+
if string(got) != string(content) {
29+
t.Errorf("content mismatch: got %q, want %q", got, content)
30+
}
31+
32+
info, err := os.Stat(dst)
33+
if err != nil {
34+
t.Fatal(err)
35+
}
36+
if info.Mode()&0o111 == 0 {
37+
t.Errorf("dst is not executable: mode %v", info.Mode())
38+
}
39+
})
40+
41+
t.Run("overwrites existing dst", func(t *testing.T) {
42+
dir := t.TempDir()
43+
src := filepath.Join(dir, "src")
44+
dst := filepath.Join(dir, "dst")
45+
46+
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
47+
t.Fatal(err)
48+
}
49+
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
50+
t.Fatal(err)
51+
}
52+
53+
if err := copyFileTo(src, dst); err != nil {
54+
t.Fatalf("copyFileTo: %v", err)
55+
}
56+
57+
got, _ := os.ReadFile(dst) //nolint:gosec // dst is a temp file path from t.TempDir()
58+
if string(got) != "new" {
59+
t.Errorf("got %q, want %q", got, "new")
60+
}
61+
})
62+
63+
t.Run("returns error for missing src", func(t *testing.T) {
64+
dir := t.TempDir()
65+
err := copyFileTo(filepath.Join(dir, "nonexistent"), filepath.Join(dir, "dst"))
66+
if err == nil {
67+
t.Fatal("expected error for missing src, got nil")
68+
}
69+
})
70+
}

‎internal/proxy/install.go‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,48 @@ func Install(opts InstallOptions) error {
164164
return nil
165165
}
166166

167+
// DownloadGreywallBinary downloads the greywall release binary for the current platform
168+
// to a temp directory and returns the path to the extracted binary.
169+
// tag must include the "v" prefix (e.g. "v0.2.0").
170+
// The caller is responsible for removing the returned directory when done.
171+
func DownloadGreywallBinary(tag string) (binPath string, cleanup func(), err error) {
172+
ver := strings.TrimPrefix(tag, "v")
173+
174+
// Goreleaser uses title-case OS and renames amd64 → x86_64
175+
// Uppercase only the first byte — safe for the known values "linux"/"darwin"
176+
goos := runtime.GOOS
177+
osName := strings.ToUpper(goos[:1]) + goos[1:]
178+
archName := runtime.GOARCH
179+
switch archName {
180+
case "amd64":
181+
archName = "x86_64"
182+
case "386":
183+
archName = "i386"
184+
}
185+
186+
assetName := fmt.Sprintf("greywall_%s_%s_%s.tar.gz", ver, osName, archName)
187+
downloadURL := fmt.Sprintf("https://github.com/GreyhavenHQ/greywall/releases/download/%s/%s", tag, assetName)
188+
189+
archivePath, err := downloadAsset(downloadURL)
190+
if err != nil {
191+
return "", nil, fmt.Errorf("failed to download greywall %s: %w", tag, err)
192+
}
193+
194+
extractDir, err := extractTarGz(archivePath)
195+
_ = os.Remove(archivePath)
196+
if err != nil {
197+
return "", nil, fmt.Errorf("failed to extract greywall archive: %w", err)
198+
}
199+
200+
bin := filepath.Join(extractDir, "greywall")
201+
if _, err := os.Stat(bin); err != nil {
202+
_ = os.RemoveAll(extractDir)
203+
return "", nil, fmt.Errorf("greywall binary not found in archive")
204+
}
205+
206+
return bin, func() { _ = os.RemoveAll(extractDir) }, nil
207+
}
208+
167209
// CheckLatestTag returns the latest greyproxy release tag (with "v" prefix).
168210
// If beta is true, returns the latest pre-release tag.
169211
func CheckLatestTag(beta bool) (string, error) {

0 commit comments

Comments
 (0)