From b0900d8d27e583aca966b2c37c7f8292a53adda8 Mon Sep 17 00:00:00 2001 From: Joyce <26967919+Joyce-O@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:40:41 -0400 Subject: [PATCH 1/3] fix: restore binary download for greywall setup and add docs for source installs --- internal/proxy/install.go | 187 ++++++++++++++++++++++++--------- internal/proxy/install_test.go | 127 ++++++++++++++++++++++ 2 files changed, 267 insertions(+), 47 deletions(-) create mode 100644 internal/proxy/install_test.go diff --git a/internal/proxy/install.go b/internal/proxy/install.go index f42843f..666b8f6 100644 --- a/internal/proxy/install.go +++ b/internal/proxy/install.go @@ -38,6 +38,8 @@ type asset struct { // InstallOptions controls the greyproxy installation behavior. type InstallOptions struct { Output io.Writer // progress output (typically os.Stderr) + Tag string // specific tag to install; if empty, uses latest stable + Beta bool // if Tag is empty and Beta is true, fetches latest pre-release tag } // CheckLatestVersion fetches the latest greyproxy release tag from GitHub @@ -82,7 +84,7 @@ func IsOlderVersion(current, latest string) bool { return false } -// Install downloads the latest greyproxy release and runs "greyproxy install". +// Install downloads the latest (or specified) greyproxy release and runs "greyproxy install". // Set GREYWALL_NO_GREYPROXY_INSTALL=1 to skip installation entirely. func Install(opts InstallOptions) error { if os.Getenv("GREYWALL_NO_GREYPROXY_INSTALL") == "1" { @@ -93,30 +95,42 @@ func Install(opts InstallOptions) error { opts.Output = os.Stderr } - // 1. Fetch latest release - _, _ = fmt.Fprintf(opts.Output, "Fetching latest greyproxy release...\n") - rel, err := fetchLatestRelease() + // Resolve which tag to install + var rel *release + var err error + switch { + case opts.Tag != "": + rel, err = fetchReleaseFor(nil, "", githubOwner, githubRepo, opts.Tag) + case opts.Beta: + tag, tagErr := fetchLatestPreReleaseTagFor(nil, "", githubOwner, githubRepo) + if tagErr != nil { + return fmt.Errorf("failed to fetch latest pre-release: %w", tagErr) + } + rel, err = fetchReleaseFor(nil, "", githubOwner, githubRepo, tag) + default: + rel, err = fetchLatestRelease() + } if err != nil { - return fmt.Errorf("failed to fetch latest release: %w", err) + return fmt.Errorf("failed to fetch release: %w", err) } - ver := strings.TrimPrefix(rel.TagName, "v") - _, _ = fmt.Fprintf(opts.Output, "Latest version: %s\n", ver) - // 2. Find the correct asset for this platform + _, _ = fmt.Fprintf(opts.Output, "Fetching greyproxy release %s...\n", rel.TagName) + + // Find the correct asset for this platform assetURL, assetName, err := resolveAssetURL(rel) if err != nil { return err } _, _ = fmt.Fprintf(opts.Output, "Downloading %s...\n", assetName) - // 3. Download to temp file + // Download to temp file archivePath, err := downloadAsset(assetURL) if err != nil { return fmt.Errorf("download failed: %w", err) } defer func() { _ = os.Remove(archivePath) }() - // 4. Extract + // Extract _, _ = fmt.Fprintf(opts.Output, "Extracting...\n") extractDir, err := extractTarGz(archivePath) if err != nil { @@ -124,19 +138,17 @@ func Install(opts InstallOptions) error { } defer func() { _ = os.RemoveAll(extractDir) }() - // 5. Find the greyproxy binary in extracted content + // Find the greyproxy binary in extracted content binaryPath := filepath.Join(extractDir, "greyproxy") if _, err := os.Stat(binaryPath); err != nil { return fmt.Errorf("greyproxy binary not found in archive") } - // 6. Shell out to "greyproxy install" _, _ = fmt.Fprintf(opts.Output, "\n") if err := runGreyproxyInstall(binaryPath); err != nil { return fmt.Errorf("greyproxy install failed: %w", err) } - // 7. Verify _, _ = fmt.Fprintf(opts.Output, "\nVerifying installation...\n") status := Detect() if status.Installed { @@ -152,37 +164,41 @@ func Install(opts InstallOptions) error { return nil } -// fetchLatestRelease queries the GitHub API for the latest greyproxy release. -func fetchLatestRelease() (*release, error) { - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", githubOwner, githubRepo) - client := &http.Client{Timeout: apiTimeout} - - ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) - defer cancel() +// CheckLatestTag returns the latest greyproxy release tag (with "v" prefix). +// If beta is true, returns the latest pre-release tag. +func CheckLatestTag(beta bool) (string, error) { + return CheckLatestTagFor(githubOwner, githubRepo, beta) +} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "greywall-setup") +// CheckLatestTagFor returns the latest release tag for any GitHub repo. +// If beta is true, returns the latest pre-release tag; otherwise returns the latest stable tag. +func CheckLatestTagFor(owner, repo string, beta bool) (string, error) { + return checkLatestTagFor(nil, "", owner, repo, beta) +} - resp, err := client.Do(req) //nolint:gosec // apiURL is built from hardcoded constants - if err != nil { - return nil, fmt.Errorf("GitHub API request failed: %w", err) +func checkLatestTagFor(client *http.Client, apiBase, owner, repo string, beta bool) (string, error) { + if !beta { + rel, err := fetchReleaseFor(client, apiBase, owner, repo, "latest") + if err != nil { + return "", err + } + return rel.TagName, nil } - defer func() { _ = resp.Body.Close() }() + return fetchLatestPreReleaseTagFor(client, apiBase, owner, repo) +} - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) - } +// fetchLatestRelease queries the GitHub API for the latest greyproxy release. +func fetchLatestRelease() (*release, error) { + return fetchReleaseFor(nil, "", githubOwner, githubRepo, "latest") +} - var rel release - if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { - return nil, fmt.Errorf("failed to parse release response: %w", err) - } - return &rel, nil +// runGreyproxyInstall shells out to the extracted greyproxy binary with "install --force". +func runGreyproxyInstall(binaryPath string) error { + cmd := exec.Command(binaryPath, "install", "--force") //nolint:gosec // binaryPath is from our extracted archive + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() } // resolveAssetURL finds the correct asset download URL for the current OS/arch. @@ -213,7 +229,7 @@ func downloadAsset(downloadURL string) (string, error) { return "", err } - resp, err := client.Do(req) //nolint:gosec // downloadURL comes from GitHub API response + resp, err := client.Do(req) //nolint:gosec // downloadURL comes from GitHub API response or hardcoded constants if err != nil { return "", err } @@ -223,7 +239,7 @@ func downloadAsset(downloadURL string) (string, error) { return "", fmt.Errorf("download returned status %d", resp.StatusCode) } - tmpFile, err := os.CreateTemp("", "greyproxy-*.tar.gz") + tmpFile, err := os.CreateTemp("", "greywall-download-*.tar.gz") if err != nil { return "", err } @@ -252,7 +268,7 @@ func extractTarGz(archivePath string) (string, error) { } defer func() { _ = gz.Close() }() - tmpDir, err := os.MkdirTemp("", "greyproxy-extract-*") + tmpDir, err := os.MkdirTemp("", "greywall-extract-*") if err != nil { return "", err } @@ -295,10 +311,87 @@ func extractTarGz(archivePath string) (string, error) { return tmpDir, nil } -// runGreyproxyInstall shells out to the extracted greyproxy binary with "install --force". -func runGreyproxyInstall(binaryPath string) error { - cmd := exec.Command(binaryPath, "install", "--force") //nolint:gosec // binaryPath is from our extracted archive - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() +// fetchReleaseFor fetches a specific GitHub release endpoint (e.g. "latest" or a tag name). +// client and apiBase are optional; nil/empty use production defaults. +func fetchReleaseFor(client *http.Client, apiBase, owner, repo, endpoint string) (*release, error) { + if client == nil { + client = &http.Client{Timeout: apiTimeout} + } + if apiBase == "" { + apiBase = "https://api.github.com" + } + apiURL := fmt.Sprintf("%s/repos/%s/%s/releases/%s", apiBase, owner, repo, endpoint) + + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "greywall-setup") + + resp, err := client.Do(req) //nolint:gosec // apiURL is built from controlled inputs + if err != nil { + return nil, fmt.Errorf("GitHub API request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + var rel release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("failed to parse release response: %w", err) + } + return &rel, nil +} + +// fetchLatestPreReleaseTagFor returns the most recent pre-release tag for the given repo. +// client and apiBase are optional; nil/empty use production defaults. +func fetchLatestPreReleaseTagFor(client *http.Client, apiBase, owner, repo string) (string, error) { + if client == nil { + client = &http.Client{Timeout: apiTimeout} + } + if apiBase == "" { + apiBase = "https://api.github.com" + } + apiURL := fmt.Sprintf("%s/repos/%s/%s/releases?per_page=20", apiBase, owner, repo) + + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "greywall-setup") + + resp, err := client.Do(req) //nolint:gosec // apiURL is built from controlled inputs + if err != nil { + return "", fmt.Errorf("GitHub API request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + var releases []struct { + TagName string `json:"tag_name"` + PreRelease bool `json:"prerelease"` + } + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return "", fmt.Errorf("failed to parse releases response: %w", err) + } + + for _, r := range releases { + if r.PreRelease { + return r.TagName, nil + } + } + return "", fmt.Errorf("no pre-release found for %s/%s", owner, repo) } diff --git a/internal/proxy/install_test.go b/internal/proxy/install_test.go new file mode 100644 index 0000000..f07746e --- /dev/null +++ b/internal/proxy/install_test.go @@ -0,0 +1,127 @@ +package proxy + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestIsOlderVersion(t *testing.T) { + tests := []struct { + current string + latest string + want bool + }{ + // Strictly older + {"1.0.0", "1.0.1", true}, + {"1.0.0", "1.1.0", true}, + {"1.0.0", "2.0.0", true}, + {"0.9.9", "1.0.0", true}, + // Same version + {"1.0.0", "1.0.0", false}, + {"2.3.4", "2.3.4", false}, + // Strictly newer + {"1.0.1", "1.0.0", false}, + {"2.0.0", "1.9.9", false}, + // Invalid current → treated as outdated + {"dev", "1.0.0", true}, + {"", "1.0.0", true}, + {"1.0", "1.0.0", true}, + // Invalid latest → not older + {"1.0.0", "dev", false}, + {"1.0.0", "", false}, + {"1.0.0", "1.0", false}, + // Non-numeric components in current → treated as outdated + {"a.b.c", "1.0.0", true}, + // Non-numeric components in latest → not older + {"1.0.0", "a.b.c", false}, + } + + for _, tc := range tests { + got := IsOlderVersion(tc.current, tc.latest) + if got != tc.want { + t.Errorf("IsOlderVersion(%q, %q) = %v, want %v", tc.current, tc.latest, got, tc.want) + } + } +} + +func TestCheckLatestTagFor_Stable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/owner/repo/releases/latest" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(release{TagName: "v1.2.3"}) + })) + defer srv.Close() + + tag, err := checkLatestTagFor(srv.Client(), srv.URL, "owner", "repo", false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tag != "v1.2.3" { + t.Errorf("got tag %q, want %q", tag, "v1.2.3") + } +} + +func TestCheckLatestTagFor_Beta(t *testing.T) { + releases := []struct { + TagName string `json:"tag_name"` + PreRelease bool `json:"prerelease"` + }{ + {TagName: "v2.0.0-beta.1", PreRelease: true}, + {TagName: "v1.9.0", PreRelease: false}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/owner/repo/releases" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(releases) + })) + defer srv.Close() + + tag, err := checkLatestTagFor(srv.Client(), srv.URL, "owner", "repo", true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tag != "v2.0.0-beta.1" { + t.Errorf("got tag %q, want %q", tag, "v2.0.0-beta.1") + } +} + +func TestCheckLatestTagFor_BetaNoneFound(t *testing.T) { + releases := []struct { + TagName string `json:"tag_name"` + PreRelease bool `json:"prerelease"` + }{ + {TagName: "v1.9.0", PreRelease: false}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(releases) + })) + defer srv.Close() + + _, err := checkLatestTagFor(srv.Client(), srv.URL, "owner", "repo", true) + if err == nil { + t.Fatal("expected error when no pre-release found, got nil") + } +} + +func TestCheckLatestTagFor_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "rate limited", http.StatusTooManyRequests) + })) + defer srv.Close() + + _, err := checkLatestTagFor(srv.Client(), srv.URL, "owner", "repo", false) + if err == nil { + t.Fatal("expected error on non-200 response, got nil") + } +} From 505b5d4af35aa631aceb763417e9d8e15a26f7cc Mon Sep 17 00:00:00 2001 From: Joyce <26967919+Joyce-O@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:27:59 -0400 Subject: [PATCH 2/3] fix lint error --- internal/proxy/install.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/proxy/install.go b/internal/proxy/install.go index 666b8f6..ad89b40 100644 --- a/internal/proxy/install.go +++ b/internal/proxy/install.go @@ -164,7 +164,6 @@ func Install(opts InstallOptions) error { return nil } - // CheckLatestTag returns the latest greyproxy release tag (with "v" prefix). // If beta is true, returns the latest pre-release tag. func CheckLatestTag(beta bool) (string, error) { From f08e8a399588cf5d2aa2f47ab9ebf0c8e46ad104 Mon Sep 17 00:00:00 2001 From: Joyce <26967919+Joyce-O@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:44:04 -0400 Subject: [PATCH 3/3] feat: add greywall update command with install-method-aware updating --- README.md | 4 + cmd/greywall/main.go | 169 ++++++++++++++++++++++++++++++++++++++ cmd/greywall/main_test.go | 70 ++++++++++++++++ internal/proxy/install.go | 62 +++++++++++++- 4 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 cmd/greywall/main_test.go diff --git a/README.md b/README.md index fe042be..f00d296 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ curl -fsSL https://raw.githubusercontent.com/GreyhavenHQ/greywall/main/install.s ```bash go install github.com/GreyhavenHQ/greywall/cmd/greywall@latest +# greyproxy is not included — install it separately: +greywall setup ``` **[mise](https://mise.jdx.dev/):** @@ -77,6 +79,8 @@ mise use -g github:GreyhavenHQ/greyproxy git clone https://github.com/GreyhavenHQ/greywall cd greywall make setup && make build +# greyproxy is not included — install it separately: +greywall setup ``` diff --git a/cmd/greywall/main.go b/cmd/greywall/main.go index 61442a1..13c45af 100644 --- a/cmd/greywall/main.go +++ b/cmd/greywall/main.go @@ -4,6 +4,7 @@ package main import ( "encoding/json" "fmt" + "io" "net/url" "os" "os/exec" @@ -141,6 +142,7 @@ Configuration file format: rootCmd.AddCommand(newProfilesCmd()) rootCmd.AddCommand(newCheckCmd()) rootCmd.AddCommand(newSetupCmd()) + rootCmd.AddCommand(newUpdateCmd()) if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -878,6 +880,173 @@ func runSetup(_ *cobra.Command, _ []string) error { }) } +// newUpdateCmd creates the update subcommand for updating greywall and greyproxy. +func newUpdateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Update greywall and greyproxy to the latest release", + Long: `Updates greywall and greyproxy by downloading the latest release binaries. + +The update method depends on how greywall was originally installed: + - Installed via install.sh → downloads and replaces binaries automatically + - Installed via Homebrew → prints the brew upgrade command to run + - Installed from source → prints manual instructions + +Examples: + greywall update # update to latest stable + greywall update --beta # update to latest beta`, + Args: cobra.NoArgs, + RunE: runUpdate, + } + cmd.Flags().Bool("beta", false, "Update to the latest beta (pre-release) version") + return cmd +} + +// installMethod represents how greywall was originally installed. +type installMethod int + +const ( + installMethodScript installMethod = iota // install.sh → ~/.local/bin + installMethodBrew // Homebrew + installMethodSource // built from source / unknown +) + +// detectInstallMethod returns how the currently running greywall was installed. +func detectInstallMethod(selfPath string) installMethod { + if proxy.IsBrewManaged(selfPath) { + return installMethodBrew + } + home, err := os.UserHomeDir() + if err == nil { + localBin := filepath.Join(home, ".local", "bin") + if filepath.Dir(selfPath) == localBin { + return installMethodScript + } + } + return installMethodSource +} + +func runUpdate(cmd *cobra.Command, _ []string) error { + beta, _ := cmd.Flags().GetBool("beta") + + // Fetch latest tags for both tools + greyproxyTag, err := proxy.CheckLatestTag(beta) + if err != nil { + if beta { + return fmt.Errorf("no beta release available for greyproxy yet — try 'greywall update' for the latest stable") + } + return fmt.Errorf("failed to fetch latest greyproxy tag: %w", err) + } + greywallTag, err := proxy.CheckLatestTagFor("GreyhavenHQ", "greywall", beta) + if err != nil { + if beta { + return fmt.Errorf("no beta release available for greywall yet — try 'greywall update' for the latest stable") + } + return fmt.Errorf("failed to fetch latest greywall tag: %w", err) + } + + channel := "stable" + if beta { + channel = "beta" + } + fmt.Printf("Latest %s: greywall %s, greyproxy %s\n\n", channel, greywallTag, greyproxyTag) + + // Detect how greywall was installed (resolve symlinks first) + selfPath, err := os.Executable() + if err != nil { + selfPath = "" + } else if resolved, err := filepath.EvalSymlinks(selfPath); err == nil { + selfPath = resolved + } + + switch detectInstallMethod(selfPath) { + case installMethodBrew: + fmt.Printf("greywall is managed by Homebrew. To update both tools, run:\n") + fmt.Printf(" brew upgrade greywall greyproxy\n") + return nil + + case installMethodSource: + fmt.Printf("greywall was installed from source. To update manually:\n\n") + fmt.Printf(" greywall:\n") + fmt.Printf(" git clone --branch %s https://github.com/GreyhavenHQ/greywall.git\n", greywallTag) + fmt.Printf(" cd greywall && make build && cp greywall ~/.local/bin/\n\n") + fmt.Printf(" greyproxy:\n") + fmt.Printf(" git clone --branch %s https://github.com/GreyhavenHQ/greyproxy.git\n", greyproxyTag) + fmt.Printf(" cd greyproxy && go build -o greyproxy ./cmd/greyproxy && greyproxy install --force\n") + return nil // exit 0 — not broken, just can't automate + + default: // installMethodScript + // Update greyproxy via binary download + fmt.Println("==> Updating greyproxy...") + greyproxyStatus := proxy.Detect() + if !proxy.IsOlderVersion(greyproxyStatus.Version, greyproxyTag) { + fmt.Printf("greyproxy is already up to date (%s)\n", greyproxyTag) + } else if err := proxy.Install(proxy.InstallOptions{ + Output: os.Stderr, + Tag: greyproxyTag, + }); err != nil { + return fmt.Errorf("failed to update greyproxy: %w", err) + } + + // Update greywall via binary download + fmt.Println("\n==> Updating greywall...") + if !proxy.IsOlderVersion(version, greywallTag) { + fmt.Printf("greywall is already up to date (%s)\n", greywallTag) + } else if err := updateSelf(greywallTag, selfPath, os.Stderr); err != nil { + return fmt.Errorf("failed to update greywall: %w", err) + } + fmt.Printf("\ngreywall and greyproxy are up to date on %s channel.\n", channel) + return nil + } +} + +// updateSelf downloads the greywall release binary and replaces the running binary at execPath. +func updateSelf(tag, execPath string, output io.Writer) error { + _, _ = fmt.Fprintf(output, "Downloading greywall %s...\n", tag) + + binPath, cleanup, err := proxy.DownloadGreywallBinary(tag) + if err != nil { + return err + } + defer cleanup() + + _, _ = fmt.Fprintf(output, "Replacing %s...\n", execPath) + if err := os.Rename(binPath, execPath); err != nil { + // Rename across filesystems fails; copy instead + if err2 := copyFileTo(binPath, execPath); err2 != nil { + return fmt.Errorf("failed to replace binary: %w (copy also failed: %v)", err, err2) + } + } + + _, _ = fmt.Fprintf(output, "greywall updated to %s\n", tag) + return nil +} + +// copyFileTo copies src to dst atomically (write to temp, then rename). +func copyFileTo(src, dst string) error { + in, err := os.Open(src) //nolint:gosec // src is a path we control + if err != nil { + return err + } + defer func() { _ = in.Close() }() + + tmpDst := dst + ".new" + out, err := os.OpenFile(tmpDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) //nolint:gosec + if err != nil { + return err + } + defer func() { _ = os.Remove(tmpDst) }() + + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + return os.Rename(tmpDst, dst) +} + // newCompletionCmd creates the completion subcommand for shell completions. func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command { cmd := &cobra.Command{ diff --git a/cmd/greywall/main_test.go b/cmd/greywall/main_test.go new file mode 100644 index 0000000..7786a2f --- /dev/null +++ b/cmd/greywall/main_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCopyFileTo(t *testing.T) { + t.Run("copies content and sets executable bit", func(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + + content := []byte("hello binary") + if err := os.WriteFile(src, content, 0o600); err != nil { + t.Fatal(err) + } + + if err := copyFileTo(src, dst); err != nil { + t.Fatalf("copyFileTo: %v", err) + } + + got, err := os.ReadFile(dst) //nolint:gosec // dst is a temp file path from t.TempDir() + if err != nil { + t.Fatalf("reading dst: %v", err) + } + if string(got) != string(content) { + t.Errorf("content mismatch: got %q, want %q", got, content) + } + + info, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + if info.Mode()&0o111 == 0 { + t.Errorf("dst is not executable: mode %v", info.Mode()) + } + }) + + t.Run("overwrites existing dst", func(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + + if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(src, []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + + if err := copyFileTo(src, dst); err != nil { + t.Fatalf("copyFileTo: %v", err) + } + + got, _ := os.ReadFile(dst) //nolint:gosec // dst is a temp file path from t.TempDir() + if string(got) != "new" { + t.Errorf("got %q, want %q", got, "new") + } + }) + + t.Run("returns error for missing src", func(t *testing.T) { + dir := t.TempDir() + err := copyFileTo(filepath.Join(dir, "nonexistent"), filepath.Join(dir, "dst")) + if err == nil { + t.Fatal("expected error for missing src, got nil") + } + }) +} diff --git a/internal/proxy/install.go b/internal/proxy/install.go index ad89b40..be92729 100644 --- a/internal/proxy/install.go +++ b/internal/proxy/install.go @@ -100,13 +100,13 @@ func Install(opts InstallOptions) error { var err error switch { case opts.Tag != "": - rel, err = fetchReleaseFor(nil, "", githubOwner, githubRepo, opts.Tag) + rel, err = fetchReleaseFor(nil, "", githubOwner, githubRepo, "tags/"+opts.Tag) case opts.Beta: tag, tagErr := fetchLatestPreReleaseTagFor(nil, "", githubOwner, githubRepo) if tagErr != nil { return fmt.Errorf("failed to fetch latest pre-release: %w", tagErr) } - rel, err = fetchReleaseFor(nil, "", githubOwner, githubRepo, tag) + rel, err = fetchReleaseFor(nil, "", githubOwner, githubRepo, "tags/"+tag) default: rel, err = fetchLatestRelease() } @@ -164,6 +164,41 @@ func Install(opts InstallOptions) error { return nil } +// DownloadGreywallBinary downloads the greywall release binary for the current platform +// to a temp directory and returns the path to the extracted binary. +// tag must include the "v" prefix (e.g. "v0.2.0"). +// The caller is responsible for removing the returned directory when done. +func DownloadGreywallBinary(tag string) (binPath string, cleanup func(), err error) { + rel, err := fetchReleaseFor(nil, "", "GreyhavenHQ", "greywall", "tags/"+tag) + if err != nil { + return "", nil, fmt.Errorf("failed to fetch greywall release %s: %w", tag, err) + } + + downloadURL, _, err := resolveGreywallAssetURL(rel) + if err != nil { + return "", nil, err + } + + archivePath, err := downloadAsset(downloadURL) + if err != nil { + return "", nil, fmt.Errorf("failed to download greywall %s: %w", tag, err) + } + + extractDir, err := extractTarGz(archivePath) + _ = os.Remove(archivePath) + if err != nil { + return "", nil, fmt.Errorf("failed to extract greywall archive: %w", err) + } + + bin := filepath.Join(extractDir, "greywall") + if _, err := os.Stat(bin); err != nil { + _ = os.RemoveAll(extractDir) + return "", nil, fmt.Errorf("greywall binary not found in archive") + } + + return bin, func() { _ = os.RemoveAll(extractDir) }, nil +} + // CheckLatestTag returns the latest greyproxy release tag (with "v" prefix). // If beta is true, returns the latest pre-release tag. func CheckLatestTag(beta bool) (string, error) { @@ -200,6 +235,29 @@ func runGreyproxyInstall(binaryPath string) error { return cmd.Run() } +// resolveGreywallAssetURL finds the correct greywall asset URL for the current OS/arch. +// Greywall uses GoReleaser defaults: title-case OS (Darwin/Linux) and x86_64 for amd64. +func resolveGreywallAssetURL(rel *release) (downloadURL, name string, err error) { + ver := strings.TrimPrefix(rel.TagName, "v") + goos := runtime.GOOS + osName := strings.ToUpper(goos[:1]) + goos[1:] + archName := runtime.GOARCH + switch archName { + case "amd64": + archName = "x86_64" + case "386": + archName = "i386" + } + + expected := fmt.Sprintf("greywall_%s_%s_%s.tar.gz", ver, osName, archName) + for _, a := range rel.Assets { + if a.Name == expected { + return a.BrowserDownloadURL, a.Name, nil + } + } + return "", "", fmt.Errorf("no greywall asset found for %s/%s (expected: %s)", goos, runtime.GOARCH, expected) +} + // resolveAssetURL finds the correct asset download URL for the current OS/arch. func resolveAssetURL(rel *release) (downloadURL, name string, err error) { ver := strings.TrimPrefix(rel.TagName, "v")