Skip to content

Commit f08e8a3

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

4 files changed

Lines changed: 303 additions & 2 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ curl -fsSL https://raw.githubusercontent.com/GreyhavenHQ/greywall/main/install.s
6262

6363
```bash
6464
go install github.com/GreyhavenHQ/greywall/cmd/greywall@latest
65+
# greyproxy is not included — install it separately:
66+
greywall setup
6567
```
6668

6769
**[mise](https://mise.jdx.dev/):**
@@ -77,6 +79,8 @@ mise use -g github:GreyhavenHQ/greyproxy
7779
git clone https://github.com/GreyhavenHQ/greywall
7880
cd greywall
7981
make setup && make build
82+
# greyproxy is not included — install it separately:
83+
greywall setup
8084
```
8185

8286
</details>

cmd/greywall/main.go

Lines changed: 169 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,173 @@ 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+
if beta {
936+
return fmt.Errorf("no beta release available for greyproxy yet — try 'greywall update' for the latest stable")
937+
}
938+
return fmt.Errorf("failed to fetch latest greyproxy tag: %w", err)
939+
}
940+
greywallTag, err := proxy.CheckLatestTagFor("GreyhavenHQ", "greywall", beta)
941+
if err != nil {
942+
if beta {
943+
return fmt.Errorf("no beta release available for greywall yet — try 'greywall update' for the latest stable")
944+
}
945+
return fmt.Errorf("failed to fetch latest greywall tag: %w", err)
946+
}
947+
948+
channel := "stable"
949+
if beta {
950+
channel = "beta"
951+
}
952+
fmt.Printf("Latest %s: greywall %s, greyproxy %s\n\n", channel, greywallTag, greyproxyTag)
953+
954+
// Detect how greywall was installed (resolve symlinks first)
955+
selfPath, err := os.Executable()
956+
if err != nil {
957+
selfPath = ""
958+
} else if resolved, err := filepath.EvalSymlinks(selfPath); err == nil {
959+
selfPath = resolved
960+
}
961+
962+
switch detectInstallMethod(selfPath) {
963+
case installMethodBrew:
964+
fmt.Printf("greywall is managed by Homebrew. To update both tools, run:\n")
965+
fmt.Printf(" brew upgrade greywall greyproxy\n")
966+
return nil
967+
968+
case installMethodSource:
969+
fmt.Printf("greywall was installed from source. To update manually:\n\n")
970+
fmt.Printf(" greywall:\n")
971+
fmt.Printf(" git clone --branch %s https://github.com/GreyhavenHQ/greywall.git\n", greywallTag)
972+
fmt.Printf(" cd greywall && make build && cp greywall ~/.local/bin/\n\n")
973+
fmt.Printf(" greyproxy:\n")
974+
fmt.Printf(" git clone --branch %s https://github.com/GreyhavenHQ/greyproxy.git\n", greyproxyTag)
975+
fmt.Printf(" cd greyproxy && go build -o greyproxy ./cmd/greyproxy && greyproxy install --force\n")
976+
return nil // exit 0 — not broken, just can't automate
977+
978+
default: // installMethodScript
979+
// Update greyproxy via binary download
980+
fmt.Println("==> Updating greyproxy...")
981+
greyproxyStatus := proxy.Detect()
982+
if !proxy.IsOlderVersion(greyproxyStatus.Version, greyproxyTag) {
983+
fmt.Printf("greyproxy is already up to date (%s)\n", greyproxyTag)
984+
} else if err := proxy.Install(proxy.InstallOptions{
985+
Output: os.Stderr,
986+
Tag: greyproxyTag,
987+
}); err != nil {
988+
return fmt.Errorf("failed to update greyproxy: %w", err)
989+
}
990+
991+
// Update greywall via binary download
992+
fmt.Println("\n==> Updating greywall...")
993+
if !proxy.IsOlderVersion(version, greywallTag) {
994+
fmt.Printf("greywall is already up to date (%s)\n", greywallTag)
995+
} else if err := updateSelf(greywallTag, selfPath, os.Stderr); err != nil {
996+
return fmt.Errorf("failed to update greywall: %w", err)
997+
}
998+
fmt.Printf("\ngreywall and greyproxy are up to date on %s channel.\n", channel)
999+
return nil
1000+
}
1001+
}
1002+
1003+
// updateSelf downloads the greywall release binary and replaces the running binary at execPath.
1004+
func updateSelf(tag, execPath string, output io.Writer) error {
1005+
_, _ = fmt.Fprintf(output, "Downloading greywall %s...\n", tag)
1006+
1007+
binPath, cleanup, err := proxy.DownloadGreywallBinary(tag)
1008+
if err != nil {
1009+
return err
1010+
}
1011+
defer cleanup()
1012+
1013+
_, _ = fmt.Fprintf(output, "Replacing %s...\n", execPath)
1014+
if err := os.Rename(binPath, execPath); err != nil {
1015+
// Rename across filesystems fails; copy instead
1016+
if err2 := copyFileTo(binPath, execPath); err2 != nil {
1017+
return fmt.Errorf("failed to replace binary: %w (copy also failed: %v)", err, err2)
1018+
}
1019+
}
1020+
1021+
_, _ = fmt.Fprintf(output, "greywall updated to %s\n", tag)
1022+
return nil
1023+
}
1024+
1025+
// copyFileTo copies src to dst atomically (write to temp, then rename).
1026+
func copyFileTo(src, dst string) error {
1027+
in, err := os.Open(src) //nolint:gosec // src is a path we control
1028+
if err != nil {
1029+
return err
1030+
}
1031+
defer func() { _ = in.Close() }()
1032+
1033+
tmpDst := dst + ".new"
1034+
out, err := os.OpenFile(tmpDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) //nolint:gosec
1035+
if err != nil {
1036+
return err
1037+
}
1038+
defer func() { _ = os.Remove(tmpDst) }()
1039+
1040+
if _, err := io.Copy(out, in); err != nil {
1041+
_ = out.Close()
1042+
return err
1043+
}
1044+
if err := out.Close(); err != nil {
1045+
return err
1046+
}
1047+
return os.Rename(tmpDst, dst)
1048+
}
1049+
8811050
// newCompletionCmd creates the completion subcommand for shell completions.
8821051
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
8831052
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: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,13 @@ func Install(opts InstallOptions) error {
100100
var err error
101101
switch {
102102
case opts.Tag != "":
103-
rel, err = fetchReleaseFor(nil, "", githubOwner, githubRepo, opts.Tag)
103+
rel, err = fetchReleaseFor(nil, "", githubOwner, githubRepo, "tags/"+opts.Tag)
104104
case opts.Beta:
105105
tag, tagErr := fetchLatestPreReleaseTagFor(nil, "", githubOwner, githubRepo)
106106
if tagErr != nil {
107107
return fmt.Errorf("failed to fetch latest pre-release: %w", tagErr)
108108
}
109-
rel, err = fetchReleaseFor(nil, "", githubOwner, githubRepo, tag)
109+
rel, err = fetchReleaseFor(nil, "", githubOwner, githubRepo, "tags/"+tag)
110110
default:
111111
rel, err = fetchLatestRelease()
112112
}
@@ -164,6 +164,41 @@ 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+
rel, err := fetchReleaseFor(nil, "", "GreyhavenHQ", "greywall", "tags/"+tag)
173+
if err != nil {
174+
return "", nil, fmt.Errorf("failed to fetch greywall release %s: %w", tag, err)
175+
}
176+
177+
downloadURL, _, err := resolveGreywallAssetURL(rel)
178+
if err != nil {
179+
return "", nil, err
180+
}
181+
182+
archivePath, err := downloadAsset(downloadURL)
183+
if err != nil {
184+
return "", nil, fmt.Errorf("failed to download greywall %s: %w", tag, err)
185+
}
186+
187+
extractDir, err := extractTarGz(archivePath)
188+
_ = os.Remove(archivePath)
189+
if err != nil {
190+
return "", nil, fmt.Errorf("failed to extract greywall archive: %w", err)
191+
}
192+
193+
bin := filepath.Join(extractDir, "greywall")
194+
if _, err := os.Stat(bin); err != nil {
195+
_ = os.RemoveAll(extractDir)
196+
return "", nil, fmt.Errorf("greywall binary not found in archive")
197+
}
198+
199+
return bin, func() { _ = os.RemoveAll(extractDir) }, nil
200+
}
201+
167202
// CheckLatestTag returns the latest greyproxy release tag (with "v" prefix).
168203
// If beta is true, returns the latest pre-release tag.
169204
func CheckLatestTag(beta bool) (string, error) {
@@ -200,6 +235,29 @@ func runGreyproxyInstall(binaryPath string) error {
200235
return cmd.Run()
201236
}
202237

238+
// resolveGreywallAssetURL finds the correct greywall asset URL for the current OS/arch.
239+
// Greywall uses GoReleaser defaults: title-case OS (Darwin/Linux) and x86_64 for amd64.
240+
func resolveGreywallAssetURL(rel *release) (downloadURL, name string, err error) {
241+
ver := strings.TrimPrefix(rel.TagName, "v")
242+
goos := runtime.GOOS
243+
osName := strings.ToUpper(goos[:1]) + goos[1:]
244+
archName := runtime.GOARCH
245+
switch archName {
246+
case "amd64":
247+
archName = "x86_64"
248+
case "386":
249+
archName = "i386"
250+
}
251+
252+
expected := fmt.Sprintf("greywall_%s_%s_%s.tar.gz", ver, osName, archName)
253+
for _, a := range rel.Assets {
254+
if a.Name == expected {
255+
return a.BrowserDownloadURL, a.Name, nil
256+
}
257+
}
258+
return "", "", fmt.Errorf("no greywall asset found for %s/%s (expected: %s)", goos, runtime.GOARCH, expected)
259+
}
260+
203261
// resolveAssetURL finds the correct asset download URL for the current OS/arch.
204262
func resolveAssetURL(rel *release) (downloadURL, name string, err error) {
205263
ver := strings.TrimPrefix(rel.TagName, "v")

0 commit comments

Comments
 (0)