From cb53a1b65815949622a4e39c79ccb34c4015d481 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:36:54 +0000 Subject: [PATCH 1/4] Initial plan From 2dcd58900c6a974859a92e1a4e7d220a5cf094f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:48:52 +0000 Subject: [PATCH 2/4] Add nillsec upgrade command for self-updating Co-authored-by: 403-html <57900160+403-html@users.noreply.github.com> --- main.go | 4 + upgrade.go | 253 +++++++++++++++++++++++++++++++++++++++ upgrade_test.go | 310 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 567 insertions(+) create mode 100644 upgrade.go create mode 100644 upgrade_test.go diff --git a/main.go b/main.go index 1c61201..2135b19 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ // nillsec remove delete a secret // nillsec edit open vault in $EDITOR // nillsec env export secrets as shell variables +// nillsec upgrade upgrade nillsec to the latest release // // The vault file is secrets.vault in the current directory unless // NILLSEC_VAULT is set. @@ -67,6 +68,8 @@ func run(args []string) error { return cmdEdit(rest) case "env": return cmdEnv(rest) + case "upgrade": + return cmdUpgrade() case "version", "--version", "-v": fmt.Println("nillsec", version) return nil @@ -372,6 +375,7 @@ Usage: nillsec remove delete a secret nillsec edit open vault contents in $EDITOR nillsec env print secrets as export statements + nillsec upgrade upgrade nillsec to the latest release nillsec version print version Environment: diff --git a/upgrade.go b/upgrade.go new file mode 100644 index 0000000..e91d3e5 --- /dev/null +++ b/upgrade.go @@ -0,0 +1,253 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +// upgradeAPIURL is the GitHub Releases API endpoint; overridable in tests. +var upgradeAPIURL = "https://api.github.com/repos/403-html/nillsec/releases/latest" + +// upgradeHTTPClient is used for all upgrade HTTP requests. +var upgradeHTTPClient = &http.Client{Timeout: 30 * time.Second} + +// executableFn returns the path to the running binary; overridable in tests. +var executableFn = os.Executable + +// githubRelease holds the fields we need from the GitHub Releases API. +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +// parseMajorVersion returns the major version number from a semver string +// such as "v1.2.3" or "2.0.0". +func parseMajorVersion(v string) (int, error) { + v = strings.TrimPrefix(v, "v") + if dot := strings.IndexByte(v, '.'); dot >= 0 { + v = v[:dot] + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("invalid version %q: %w", v, err) + } + return n, nil +} + +// upgradeAssetName returns the expected GitHub release asset filename for the +// current OS and CPU architecture. +func upgradeAssetName() string { + arch := runtime.GOARCH + if arch == "arm" { + arch = "armv7" + } + name := fmt.Sprintf("nillsec-%s-%s", runtime.GOOS, arch) + if runtime.GOOS == "windows" { + return name + ".exe" + } + return name + ".tar.gz" +} + +// cmdUpgrade checks for a newer release on GitHub and, if found, downloads and +// replaces the running binary. +func cmdUpgrade() error { + if version == "dev" { + fmt.Fprintln(os.Stderr, "nillsec: upgrade is not available for development builds.") + return nil + } + + fmt.Println("Checking for updates...") + + rel, err := fetchLatestRelease() + if err != nil { + return fmt.Errorf("checking for updates: %w", err) + } + + latest := rel.TagName + if latest == version { + fmt.Printf("nillsec is already up to date (%s).\n", version) + return nil + } + + curMajor, err := parseMajorVersion(version) + if err != nil { + return fmt.Errorf("parsing current version %q: %w", version, err) + } + latestMajor, err := parseMajorVersion(latest) + if err != nil { + return fmt.Errorf("parsing latest version %q: %w", latest, err) + } + + fmt.Printf("Update available: %s → %s\n", version, latest) + + if latestMajor > curMajor { + fmt.Fprintf(os.Stderr, "Warning: this is a major version update (v%d → v%d) and may introduce breaking changes.\n", curMajor, latestMajor) + fmt.Fprint(os.Stderr, "Are you sure you want to continue? [y/N] ") + line, err := stdinReader.ReadString('\n') + if err != nil && line == "" { + // Unreadable stdin: default to "no" for safety. + fmt.Fprintln(os.Stderr) + fmt.Println("Upgrade cancelled.") + return nil + } + answer := strings.TrimRight(line, "\r\n") + if !strings.EqualFold(strings.TrimSpace(answer), "y") { + fmt.Println("Upgrade cancelled.") + return nil + } + } + + assetName := upgradeAssetName() + var downloadURL string + for _, asset := range rel.Assets { + if asset.Name == assetName { + downloadURL = asset.BrowserDownloadURL + break + } + } + if downloadURL == "" { + return fmt.Errorf("no release asset found for %s/%s (expected %q)", runtime.GOOS, runtime.GOARCH, assetName) + } + + exePath, err := executableFn() + if err != nil { + return fmt.Errorf("finding executable path: %w", err) + } + + fmt.Printf("Downloading %s...\n", assetName) + if err := downloadAndInstall(downloadURL, assetName, exePath); err != nil { + return fmt.Errorf("installing update: %w", err) + } + + fmt.Printf("nillsec updated to %s.\n", latest) + return nil +} + +// fetchLatestRelease queries the GitHub Releases API for the latest release. +func fetchLatestRelease() (*githubRelease, error) { + req, err := http.NewRequest(http.MethodGet, upgradeAPIURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "nillsec/"+version) + + resp, err := upgradeHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned %s", resp.Status) + } + + var rel githubRelease + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + return &rel, nil +} + +// maxDownloadBytes is the maximum binary size we'll accept (50 MiB). +const maxDownloadBytes = 50 << 20 + +// downloadAndInstall downloads the new binary from url, extracts it from a +// tar.gz archive if necessary, and atomically replaces the binary at exePath. +func downloadAndInstall(url, assetName, exePath string) error { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "nillsec/"+version) + + resp, err := upgradeHTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed: HTTP %s", resp.Status) + } + + // Write to a temp file in the same directory as the binary to ensure + // os.Rename works (requires the same filesystem). + dir := filepath.Dir(exePath) + tmp, err := os.CreateTemp(dir, ".nillsec-upgrade-*") + if err != nil { + return fmt.Errorf("creating temp file in %s (check write permissions): %w", dir, err) + } + tmpName := tmp.Name() + ok := false + defer func() { + tmp.Close() + if !ok { + os.Remove(tmpName) //nolint:errcheck + } + }() + + body := io.LimitReader(resp.Body, maxDownloadBytes) + if strings.HasSuffix(assetName, ".tar.gz") { + gz, err := gzip.NewReader(body) + if err != nil { + return fmt.Errorf("reading gzip: %w", err) + } + defer gz.Close() + + binaryName := strings.TrimSuffix(assetName, ".tar.gz") + tr := tar.NewReader(gz) + found := false + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("reading tar: %w", err) + } + if hdr.Name == binaryName { + if _, err := io.Copy(tmp, io.LimitReader(tr, maxDownloadBytes)); err != nil { //nolint:gosec + return fmt.Errorf("writing binary: %w", err) + } + found = true + break + } + } + if !found { + return fmt.Errorf("binary %q not found in archive", binaryName) + } + } else { + if _, err := io.Copy(tmp, body); err != nil { //nolint:gosec + return fmt.Errorf("writing binary: %w", err) + } + } + + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing temp file: %w", err) + } + + if err := os.Chmod(tmpName, 0o755); err != nil { + return fmt.Errorf("setting file permissions: %w", err) + } + + if err := os.Rename(tmpName, exePath); err != nil { + return fmt.Errorf("replacing binary (try with elevated privileges): %w", err) + } + + ok = true + return nil +} diff --git a/upgrade_test.go b/upgrade_test.go new file mode 100644 index 0000000..32f09f4 --- /dev/null +++ b/upgrade_test.go @@ -0,0 +1,310 @@ +package main + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/gzip" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestParseMajorVersion(t *testing.T) { + tests := []struct { + input string + want int + wantErr bool + }{ + {"v1.2.3", 1, false}, + {"v2.0.0", 2, false}, + {"v10.1.0", 10, false}, + {"1.0.0", 1, false}, // without 'v' prefix + {"v0.1.0", 0, false}, + {"invalid", 0, true}, + {"v.1.0", 0, true}, + } + for _, tt := range tests { + got, err := parseMajorVersion(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("parseMajorVersion(%q) = %d, nil; want error", tt.input, got) + } + continue + } + if err != nil { + t.Errorf("parseMajorVersion(%q) unexpected error: %v", tt.input, err) + continue + } + if got != tt.want { + t.Errorf("parseMajorVersion(%q) = %d; want %d", tt.input, got, tt.want) + } + } +} + +func TestUpgradeAssetName(t *testing.T) { + name := upgradeAssetName() + + if !strings.Contains(name, runtime.GOOS) { + t.Errorf("asset name %q does not contain GOOS %q", name, runtime.GOOS) + } + + if runtime.GOOS == "windows" { + if !strings.HasSuffix(name, ".exe") { + t.Errorf("Windows asset name %q should end in .exe", name) + } + } else { + if !strings.HasSuffix(name, ".tar.gz") { + t.Errorf("non-Windows asset name %q should end in .tar.gz", name) + } + } + + if runtime.GOARCH == "arm" && !strings.Contains(name, "armv7") { + t.Errorf("arm asset name %q should contain armv7", name) + } +} + +func TestCmdUpgradeDevVersion(t *testing.T) { + origVersion := version + t.Cleanup(func() { version = origVersion }) + version = "dev" + + if err := cmdUpgrade(); err != nil { + t.Errorf("cmdUpgrade with dev version: unexpected error: %v", err) + } +} + +func TestCmdUpgradeAlreadyUpToDate(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rel := githubRelease{TagName: "v1.2.3"} + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(rel); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + })) + t.Cleanup(srv.Close) + + origVersion := version + origAPIURL := upgradeAPIURL + origClient := upgradeHTTPClient + t.Cleanup(func() { + version = origVersion + upgradeAPIURL = origAPIURL + upgradeHTTPClient = origClient + }) + + version = "v1.2.3" + upgradeAPIURL = srv.URL + upgradeHTTPClient = srv.Client() + + if err := cmdUpgrade(); err != nil { + t.Errorf("cmdUpgrade (already up to date): unexpected error: %v", err) + } +} + +func TestCmdUpgradeMajorVersionCancelled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rel := githubRelease{TagName: "v2.0.0"} + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(rel); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + })) + t.Cleanup(srv.Close) + + origVersion := version + origAPIURL := upgradeAPIURL + origClient := upgradeHTTPClient + origStdin := stdinReader + t.Cleanup(func() { + version = origVersion + upgradeAPIURL = origAPIURL + upgradeHTTPClient = origClient + stdinReader = origStdin + }) + + version = "v1.0.0" + upgradeAPIURL = srv.URL + upgradeHTTPClient = srv.Client() + stdinReader = bufio.NewReader(strings.NewReader("n\n")) + + if err := cmdUpgrade(); err != nil { + t.Errorf("cmdUpgrade (major, cancelled): unexpected error: %v", err) + } +} + +// makeFakeTarGz creates a tar.gz archive in memory containing a single file +// with the given name and content. +func makeFakeTarGz(t *testing.T, binaryName, content string) []byte { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + data := []byte(content) + hdr := &tar.Header{ + Name: binaryName, + Mode: 0o755, + Size: int64(len(data)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("tar WriteHeader: %v", err) + } + if _, err := tw.Write(data); err != nil { + t.Fatalf("tar Write: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("tar Close: %v", err) + } + if err := gw.Close(); err != nil { + t.Fatalf("gzip Close: %v", err) + } + return buf.Bytes() +} + +func TestCmdUpgradeMinorVersion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("tar.gz download test not applicable on Windows") + } + + assetName := upgradeAssetName() + binaryName := strings.TrimSuffix(assetName, ".tar.gz") + fakeContent := "#!/bin/sh\necho fake\n" + tarData := makeFakeTarGz(t, binaryName, fakeContent) + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/releases/latest") { + rel := githubRelease{ + TagName: "v1.3.0", + } + rel.Assets = append(rel.Assets, struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + }{ + Name: assetName, + BrowserDownloadURL: srv.URL + "/download/" + assetName, + }) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(rel); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + if _, err := w.Write(tarData); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + })) + t.Cleanup(srv.Close) + + dir := t.TempDir() + fakeExe := filepath.Join(dir, "nillsec") + if err := os.WriteFile(fakeExe, []byte("old"), 0o755); err != nil { + t.Fatalf("writing fake exe: %v", err) + } + + origVersion := version + origAPIURL := upgradeAPIURL + origClient := upgradeHTTPClient + origExe := executableFn + t.Cleanup(func() { + version = origVersion + upgradeAPIURL = origAPIURL + upgradeHTTPClient = origClient + executableFn = origExe + }) + + version = "v1.2.0" + upgradeAPIURL = srv.URL + "/releases/latest" + upgradeHTTPClient = srv.Client() + executableFn = func() (string, error) { return fakeExe, nil } + + if err := cmdUpgrade(); err != nil { + t.Fatalf("cmdUpgrade (minor): unexpected error: %v", err) + } + + got, err := os.ReadFile(fakeExe) + if err != nil { + t.Fatalf("reading updated exe: %v", err) + } + if string(got) != fakeContent { + t.Errorf("updated binary content = %q; want %q", string(got), fakeContent) + } +} + +func TestCmdUpgradeMajorVersionConfirmed(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("tar.gz download test not applicable on Windows") + } + + assetName := upgradeAssetName() + binaryName := strings.TrimSuffix(assetName, ".tar.gz") + fakeContent := "#!/bin/sh\necho upgraded\n" + tarData := makeFakeTarGz(t, binaryName, fakeContent) + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/releases/latest") { + rel := githubRelease{ + TagName: "v2.0.0", + } + rel.Assets = append(rel.Assets, struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + }{ + Name: assetName, + BrowserDownloadURL: srv.URL + "/download/" + assetName, + }) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(rel); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + if _, err := w.Write(tarData); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + })) + t.Cleanup(srv.Close) + + dir := t.TempDir() + fakeExe := filepath.Join(dir, "nillsec") + if err := os.WriteFile(fakeExe, []byte("old"), 0o755); err != nil { + t.Fatalf("writing fake exe: %v", err) + } + + origVersion := version + origAPIURL := upgradeAPIURL + origClient := upgradeHTTPClient + origExe := executableFn + origStdin := stdinReader + t.Cleanup(func() { + version = origVersion + upgradeAPIURL = origAPIURL + upgradeHTTPClient = origClient + executableFn = origExe + stdinReader = origStdin + }) + + version = "v1.0.0" + upgradeAPIURL = srv.URL + "/releases/latest" + upgradeHTTPClient = srv.Client() + executableFn = func() (string, error) { return fakeExe, nil } + stdinReader = bufio.NewReader(strings.NewReader("y\n")) + + if err := cmdUpgrade(); err != nil { + t.Fatalf("cmdUpgrade (major, confirmed): unexpected error: %v", err) + } + + got, err := os.ReadFile(fakeExe) + if err != nil { + t.Fatalf("reading updated exe: %v", err) + } + if string(got) != fakeContent { + t.Errorf("updated binary content = %q; want %q", string(got), fakeContent) + } +} From 33caa882894abe1ac4484bcbc0a6bdcee38134c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:04:46 +0000 Subject: [PATCH 3/4] docs: add upgrade section to README Co-authored-by: 403-html <57900160+403-html@users.noreply.github.com> --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 8abe699..c7619af 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,18 @@ eval "$(nillsec env)" # Sets DATABASE_PASSWORD and API_TOKEN in the current shell. ``` +### Upgrade to the latest release + +```sh +nillsec upgrade +``` + +`nillsec upgrade` fetches the latest release from GitHub, replaces the running +binary in-place, and exits. If the latest release is a **major version bump** +(e.g. v1 → v2), you will be warned that breaking changes may be present and +asked to confirm before the download begins. If you are already on the latest +version, the command simply tells you so and exits without making any changes. + ## Environment variables | Variable | Description | Default | From e14002e20172b7831443ae7dcfc049ba9d633604 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:19:46 +0000 Subject: [PATCH 4/4] fix: preserve original input in parseMajorVersion error message; normalize version prefix in comparison Co-authored-by: 403-html <57900160+403-html@users.noreply.github.com> --- upgrade.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/upgrade.go b/upgrade.go index e91d3e5..f1420f0 100644 --- a/upgrade.go +++ b/upgrade.go @@ -36,13 +36,14 @@ type githubRelease struct { // parseMajorVersion returns the major version number from a semver string // such as "v1.2.3" or "2.0.0". func parseMajorVersion(v string) (int, error) { + orig := v v = strings.TrimPrefix(v, "v") if dot := strings.IndexByte(v, '.'); dot >= 0 { v = v[:dot] } n, err := strconv.Atoi(v) if err != nil { - return 0, fmt.Errorf("invalid version %q: %w", v, err) + return 0, fmt.Errorf("invalid version %q: %w", orig, err) } return n, nil } @@ -77,7 +78,7 @@ func cmdUpgrade() error { } latest := rel.TagName - if latest == version { + if strings.TrimPrefix(latest, "v") == strings.TrimPrefix(version, "v") { fmt.Printf("nillsec is already up to date (%s).\n", version) return nil }