feat: NSIS Windows installer and update check (#32)
- windows/installer.nsi: installs to Program Files, manages system PATH, registers uninstaller - relspec update [--check] [--yes]: compares against the latest release and prompts; on Windows downloads and runs the installer - release workflow builds and uploads relspec-setup-windows-amd64.exe - make installer-windows
This commit is contained in:
@@ -78,6 +78,13 @@ jobs:
|
||||
echo "Built $NAME"
|
||||
done
|
||||
|
||||
- name: Build Windows installer
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nsis
|
||||
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
makensis -DVERSION="${VERSION#v}" -DEXE="$PWD/relspec-windows-amd64.exe" -DOUT="$PWD/relspec-setup-windows-amd64.exe" windows/installer.nsi
|
||||
|
||||
- name: Create release and upload assets
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build test test-unit test-integration lint coverage clean install help docker-up docker-down docker-test docker-test-integration start stop release release-version rerelease godoc vet fmt fmt-check staticcheck govulncheck check
|
||||
.PHONY: installer-windows all build test test-unit test-integration lint coverage clean install help docker-up docker-down docker-test docker-test-integration start stop release release-version rerelease godoc vet fmt fmt-check staticcheck govulncheck check
|
||||
|
||||
# Binary name
|
||||
BINARY_NAME=relspec
|
||||
@@ -270,3 +270,8 @@ rerelease: lint fmt-check ## Move the latest tag to HEAD and force push it
|
||||
|
||||
help: ## Display this help screen
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
installer-windows: ## Build the Windows binary and NSIS installer (requires makensis)
|
||||
@echo "Building Windows installer..."
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -trimpath -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/relspec-windows-amd64.exe ./cmd/relspec
|
||||
makensis -DVERSION=$$(cat VERSION | tr -d '[:space:]') -DEXE=$(CURDIR)/$(BUILD_DIR)/relspec-windows-amd64.exe -DOUT=$(CURDIR)/$(BUILD_DIR)/relspec-setup-windows-amd64.exe windows/installer.nsi
|
||||
|
||||
@@ -51,6 +51,7 @@ func init() {
|
||||
rootCmd.AddCommand(mergeCmd)
|
||||
rootCmd.AddCommand(splitCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
rootCmd.AddCommand(reportCmd)
|
||||
rootCmd.PersistentFlags().BoolVar(&prisma7, "prisma7", false, "Use Prisma 7 generator conventions when reading/writing Prisma schemas")
|
||||
rootCmd.PersistentFlags().StringArrayVar(&typeMapFlags, "type-map", nil, "Override a SQL-to-Go type mapping for bun/gorm output as sqltype=gotype (repeatable), e.g. --type-map uuid=uuid.UUID --type-map numeric=decimal.Decimal")
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/buildinfo"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/updatecheck"
|
||||
)
|
||||
|
||||
// windowsInstallerAsset is the release asset built by the NSIS installer step.
|
||||
const windowsInstallerAsset = "relspec-setup-windows-amd64.exe"
|
||||
|
||||
var (
|
||||
updateCheckOnly bool
|
||||
updateAssumeYes bool
|
||||
updateAPIURL = updatecheck.DefaultAPIURL
|
||||
)
|
||||
|
||||
var updateCmd = &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Check for a newer release and offer to install it",
|
||||
Long: `Check the project releases for a version newer than this binary.
|
||||
|
||||
If one exists you are prompted to update. On Windows the installer is
|
||||
downloaded and started; on other platforms the release page URL is shown.
|
||||
Use --check to only report, and --yes to skip the prompt.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
return runUpdate(ctx, updateOptions{
|
||||
current: buildinfo.Version,
|
||||
apiURL: updateAPIURL,
|
||||
goos: runtime.GOOS,
|
||||
checkOnly: updateCheckOnly,
|
||||
assumeYes: updateAssumeYes,
|
||||
in: cmd.InOrStdin(),
|
||||
out: cmd.OutOrStdout(),
|
||||
install: downloadAndRunInstaller,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
updateCmd.Flags().BoolVar(&updateCheckOnly, "check", false, "Only report whether an update is available")
|
||||
updateCmd.Flags().BoolVarP(&updateAssumeYes, "yes", "y", false, "Update without prompting")
|
||||
}
|
||||
|
||||
type updateOptions struct {
|
||||
current string
|
||||
apiURL string
|
||||
goos string
|
||||
checkOnly bool
|
||||
assumeYes bool
|
||||
in io.Reader
|
||||
out io.Writer
|
||||
// install downloads and starts the installer found at url.
|
||||
install func(ctx context.Context, url string, out io.Writer) error
|
||||
}
|
||||
|
||||
func runUpdate(ctx context.Context, o updateOptions) error {
|
||||
rel, err := updatecheck.Latest(ctx, nil, o.apiURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !updatecheck.IsNewer(o.current, rel.Tag) {
|
||||
_, _ = fmt.Fprintf(o.out, "RelSpec %s is up to date (latest release: %s)\n", o.current, rel.Tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(o.out, "A newer RelSpec is available: %s (installed: %s)\n", rel.Tag, o.current)
|
||||
if o.checkOnly {
|
||||
_, _ = fmt.Fprintf(o.out, "Release: %s\n", rel.URL)
|
||||
return nil
|
||||
}
|
||||
|
||||
installer, canInstall := rel.FindAsset(windowsInstallerAsset)
|
||||
if o.goos != "windows" || !canInstall {
|
||||
_, _ = fmt.Fprintf(o.out, "Download it from: %s\n", rel.URL)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !o.assumeYes && !confirm(o.in, o.out, "Download and run the installer now?") {
|
||||
_, _ = fmt.Fprintf(o.out, "Skipped. Release: %s\n", rel.URL)
|
||||
return nil
|
||||
}
|
||||
return o.install(ctx, installer.URL, o.out)
|
||||
}
|
||||
|
||||
// confirm asks a yes/no question, defaulting to no.
|
||||
func confirm(in io.Reader, out io.Writer, question string) bool {
|
||||
_, _ = fmt.Fprintf(out, "%s [y/N]: ", question)
|
||||
line, _ := bufio.NewReader(in).ReadString('\n')
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "y", "yes":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// downloadAndRunInstaller saves the installer to a temp directory and starts
|
||||
// it detached so this process can exit and release relspec.exe.
|
||||
func downloadAndRunInstaller(ctx context.Context, url string, out io.Writer) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading installer: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("downloading installer: unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", "relspec-update-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, windowsInstallerAsset)
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("downloading installer: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "Starting installer: %s\n", path)
|
||||
return exec.Command(path).Start()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func releaseServer(t *testing.T, tag string, withInstaller bool) *httptest.Server {
|
||||
t.Helper()
|
||||
assets := ""
|
||||
if withInstaller {
|
||||
assets = fmt.Sprintf(`{"name":%q,"browser_download_url":"https://x/setup.exe"}`, windowsInstallerAsset)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, `{"tag_name":%q,"html_url":"https://x/release","assets":[%s]}`, tag, assets)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestRunUpdate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current string
|
||||
latest string
|
||||
withInstaller bool
|
||||
goos string
|
||||
checkOnly bool
|
||||
assumeYes bool
|
||||
stdin string
|
||||
wantOut []string
|
||||
wantInstall bool
|
||||
}{
|
||||
{name: "up to date", current: "v1.0.5", latest: "v1.0.5", goos: "windows", withInstaller: true, wantOut: []string{"up to date"}},
|
||||
{name: "dev build never prompts", current: "dev", latest: "v9.0.0", goos: "windows", withInstaller: true, wantOut: []string{"up to date"}},
|
||||
{name: "check only", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, checkOnly: true, wantOut: []string{"v1.0.6", "https://x/release"}},
|
||||
{name: "non-windows shows url", current: "v1.0.5", latest: "v1.0.6", goos: "linux", withInstaller: true, wantOut: []string{"Download it from: https://x/release"}},
|
||||
{name: "windows without installer asset", current: "v1.0.5", latest: "v1.0.6", goos: "windows", wantOut: []string{"Download it from"}},
|
||||
{name: "windows prompt yes", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, stdin: "y\n", wantOut: []string{"[y/N]"}, wantInstall: true},
|
||||
{name: "windows prompt no", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, stdin: "n\n", wantOut: []string{"Skipped"}},
|
||||
{name: "windows prompt empty", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, wantOut: []string{"Skipped"}},
|
||||
{name: "windows --yes", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, assumeYes: true, wantInstall: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := releaseServer(t, tt.latest, tt.withInstaller)
|
||||
var out bytes.Buffer
|
||||
var installedURL string
|
||||
err := runUpdate(context.Background(), updateOptions{
|
||||
current: tt.current, apiURL: srv.URL, goos: tt.goos,
|
||||
checkOnly: tt.checkOnly, assumeYes: tt.assumeYes,
|
||||
in: strings.NewReader(tt.stdin), out: &out,
|
||||
install: func(_ context.Context, url string, _ io.Writer) error {
|
||||
installedURL = url
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range tt.wantOut {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
if (installedURL != "") != tt.wantInstall {
|
||||
t.Errorf("installer called = %v, want %v", installedURL != "", tt.wantInstall)
|
||||
}
|
||||
if tt.wantInstall && installedURL != "https://x/setup.exe" {
|
||||
t.Errorf("installer url = %q", installedURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdate_Errors(t *testing.T) {
|
||||
bad := httptest.NewServer(http.NotFoundHandler())
|
||||
defer bad.Close()
|
||||
if err := runUpdate(context.Background(), updateOptions{current: "v1.0.0", apiURL: bad.URL, out: io.Discard}); err == nil {
|
||||
t.Error("expected lookup error")
|
||||
}
|
||||
|
||||
srv := releaseServer(t, "v1.0.6", true)
|
||||
want := errors.New("boom")
|
||||
err := runUpdate(context.Background(), updateOptions{
|
||||
current: "v1.0.5", apiURL: srv.URL, goos: "windows", assumeYes: true, out: io.Discard,
|
||||
install: func(context.Context, string, io.Writer) error { return want },
|
||||
})
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAndRunInstaller_BadStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.NotFoundHandler())
|
||||
defer srv.Close()
|
||||
if err := downloadAndRunInstaller(context.Background(), srv.URL, io.Discard); err == nil {
|
||||
t.Error("expected error for non-200")
|
||||
}
|
||||
if err := downloadAndRunInstaller(context.Background(), "://bad", io.Discard); err == nil {
|
||||
t.Error("expected error for bad url")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package updatecheck looks up the latest RelSpec release on the project's
|
||||
// Gitea instance and compares it against the running version.
|
||||
package updatecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultAPIURL is the Gitea endpoint that returns the latest release.
|
||||
const DefaultAPIURL = "https://git.warky.dev/api/v1/repos/wdevs/relspecgo/releases/latest"
|
||||
|
||||
// Asset is a downloadable file attached to a release.
|
||||
type Asset struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
// Release is the subset of the Gitea release payload that is needed.
|
||||
type Release struct {
|
||||
Tag string `json:"tag_name"`
|
||||
URL string `json:"html_url"`
|
||||
Assets []Asset `json:"assets"`
|
||||
}
|
||||
|
||||
// Latest fetches the latest release from apiURL. A nil client uses a client
|
||||
// with a 10 second timeout.
|
||||
func Latest(ctx context.Context, client *http.Client, apiURL string) (*Release, error) {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("checking for updates: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("checking for updates: unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
var rel Release
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return nil, fmt.Errorf("decoding release: %w", err)
|
||||
}
|
||||
if rel.Tag == "" {
|
||||
return nil, fmt.Errorf("release has no tag")
|
||||
}
|
||||
return &rel, nil
|
||||
}
|
||||
|
||||
// FindAsset returns the first asset with the given name.
|
||||
func (r *Release) FindAsset(name string) (Asset, bool) {
|
||||
for _, a := range r.Assets {
|
||||
if a.Name == name {
|
||||
return a, true
|
||||
}
|
||||
}
|
||||
return Asset{}, false
|
||||
}
|
||||
|
||||
// IsNewer reports whether latest is a higher version than current. Versions
|
||||
// that are not dotted numbers (for example "dev" or a commit hash) are never
|
||||
// considered outdated, so development builds are not nagged.
|
||||
func IsNewer(current, latest string) bool {
|
||||
cur, ok := parseVersion(current)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
lat, ok := parseVersion(latest)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for i := range cur {
|
||||
if lat[i] != cur[i] {
|
||||
return lat[i] > cur[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseVersion parses "v1.2.3" style versions into three numeric parts.
|
||||
// Missing parts are zero; any pre-release or build suffix is ignored.
|
||||
func parseVersion(v string) ([3]int, bool) {
|
||||
var out [3]int
|
||||
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
|
||||
if i := strings.IndexAny(v, "-+ "); i >= 0 {
|
||||
v = v[:i]
|
||||
}
|
||||
if v == "" {
|
||||
return out, false
|
||||
}
|
||||
parts := strings.Split(v, ".")
|
||||
if len(parts) > 3 {
|
||||
return out, false
|
||||
}
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 {
|
||||
return out, false
|
||||
}
|
||||
out[i] = n
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package updatecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsNewer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current, latest string
|
||||
want bool
|
||||
}{
|
||||
{"patch bump", "v1.0.85", "v1.0.86", true},
|
||||
{"minor beats patch", "v1.0.99", "v1.1.0", true},
|
||||
{"major bump", "v1.9.9", "v2.0.0", true},
|
||||
{"numeric not lexical", "v1.0.9", "v1.0.10", true},
|
||||
{"same", "v1.0.85", "v1.0.85", false},
|
||||
{"older latest", "v1.0.86", "v1.0.85", false},
|
||||
{"no v prefix", "1.0.1", "v1.0.2", true},
|
||||
{"short version", "v1.0", "v1.0.1", true},
|
||||
{"prerelease suffix ignored", "v1.0.1-rc1", "v1.0.2", true},
|
||||
{"dev build", "dev", "v9.9.9", false},
|
||||
{"commit hash", "abc1234", "v9.9.9", false},
|
||||
{"empty current", "", "v1.0.0", false},
|
||||
{"bad latest", "v1.0.0", "latest", false},
|
||||
{"too many parts", "v1.0.0.0", "v1.0.1", false},
|
||||
{"negative", "v1.-1.0", "v1.0.0", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsNewer(tt.current, tt.latest); got != tt.want {
|
||||
t.Errorf("IsNewer(%q,%q) = %v, want %v", tt.current, tt.latest, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
wantTag string
|
||||
wantErr bool
|
||||
}{
|
||||
{"ok", 200, `{"tag_name":"v1.2.3","html_url":"https://x/r","assets":[{"name":"a.exe","browser_download_url":"https://x/a.exe"}]}`, "v1.2.3", false},
|
||||
{"not found", 404, `{}`, "", true},
|
||||
{"bad json", 200, `{`, "", true},
|
||||
{"missing tag", 200, `{"html_url":"u"}`, "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tt.status)
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rel, err := Latest(context.Background(), nil, srv.URL)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if err == nil {
|
||||
if rel.Tag != tt.wantTag {
|
||||
t.Errorf("tag = %q", rel.Tag)
|
||||
}
|
||||
if a, ok := rel.FindAsset("a.exe"); !ok || a.URL != "https://x/a.exe" {
|
||||
t.Errorf("asset = %+v %v", a, ok)
|
||||
}
|
||||
if _, ok := rel.FindAsset("missing"); ok {
|
||||
t.Error("unexpected asset")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestUnreachable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.NotFoundHandler())
|
||||
url := srv.URL
|
||||
srv.Close()
|
||||
if _, err := Latest(context.Background(), nil, url); err == nil {
|
||||
t.Error("expected error for unreachable server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestBadURL(t *testing.T) {
|
||||
if _, err := Latest(context.Background(), nil, "://bad"); err == nil {
|
||||
t.Error("expected error for bad URL")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Windows installer
|
||||
|
||||
NSIS script: `installer.nsi`. Output: `build/relspec-setup-windows-amd64.exe`.
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
make installer-windows
|
||||
```
|
||||
|
||||
Needs `makensis` (package `nsis`). CI builds and uploads it on release.
|
||||
|
||||
## Installer
|
||||
|
||||
- Installs `relspec.exe` to `%ProgramFiles%\RelSpec` (admin).
|
||||
- Adds the install dir to the system `PATH`; removed on uninstall.
|
||||
- Registers in Add/Remove Programs.
|
||||
|
||||
## Update check
|
||||
|
||||
```
|
||||
relspec update # check, prompt, download + run installer (Windows)
|
||||
relspec update --check # report only
|
||||
relspec update --yes # no prompt
|
||||
```
|
||||
|
||||
- Source: latest Gitea release (`pkg/updatecheck`).
|
||||
- `dev` and commit-hash builds are never reported as outdated.
|
||||
- Non-Windows: prints the release URL.
|
||||
@@ -0,0 +1,111 @@
|
||||
; RelSpec Windows installer (NSIS)
|
||||
;
|
||||
; Build: makensis -DVERSION=1.2.3 -DEXE=<abs path to relspec.exe> -DOUT=<abs path to installer> windows/installer.nsi
|
||||
; Use absolute paths for EXE and OUT; relative ones resolve unpredictably.
|
||||
|
||||
!ifndef VERSION
|
||||
!define VERSION "0.0.0"
|
||||
!endif
|
||||
!ifndef OUT
|
||||
!define OUT "relspec-setup-windows-amd64.exe"
|
||||
!endif
|
||||
!ifndef EXE
|
||||
!define EXE "relspec-windows-amd64.exe"
|
||||
!endif
|
||||
|
||||
!define APPNAME "RelSpec"
|
||||
!define REGKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\RelSpec"
|
||||
!define ENVKEY "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
|
||||
|
||||
Unicode true
|
||||
Name "${APPNAME} ${VERSION}"
|
||||
OutFile "${OUT}"
|
||||
InstallDir "$PROGRAMFILES64\${APPNAME}"
|
||||
InstallDirRegKey HKLM "Software\${APPNAME}" "InstallDir"
|
||||
RequestExecutionLevel admin
|
||||
SetCompressor /SOLID lzma
|
||||
|
||||
VIProductVersion "${VERSION}.0"
|
||||
VIAddVersionKey "ProductName" "${APPNAME}"
|
||||
VIAddVersionKey "FileDescription" "${APPNAME} installer"
|
||||
VIAddVersionKey "FileVersion" "${VERSION}"
|
||||
VIAddVersionKey "ProductVersion" "${VERSION}"
|
||||
|
||||
!include "MUI2.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
!include "StrFunc.nsh"
|
||||
!include "WinMessages.nsh"
|
||||
${StrStr}
|
||||
${StrRep}
|
||||
${UnStrRep}
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
!insertmacro MUI_UNPAGE_CONFIRM
|
||||
!insertmacro MUI_UNPAGE_INSTFILES
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
|
||||
Section "Install"
|
||||
SetRegView 64
|
||||
SetOutPath "$INSTDIR"
|
||||
File "/oname=relspec.exe" "${EXE}"
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
WriteRegStr HKLM "Software\${APPNAME}" "InstallDir" "$INSTDIR"
|
||||
WriteRegStr HKLM "${REGKEY}" "DisplayName" "${APPNAME}"
|
||||
WriteRegStr HKLM "${REGKEY}" "DisplayVersion" "${VERSION}"
|
||||
WriteRegStr HKLM "${REGKEY}" "Publisher" "Warky Devs"
|
||||
WriteRegStr HKLM "${REGKEY}" "DisplayIcon" "$INSTDIR\relspec.exe"
|
||||
WriteRegStr HKLM "${REGKEY}" "InstallLocation" "$INSTDIR"
|
||||
WriteRegStr HKLM "${REGKEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||
WriteRegDWORD HKLM "${REGKEY}" "NoModify" 1
|
||||
WriteRegDWORD HKLM "${REGKEY}" "NoRepair" 1
|
||||
|
||||
; Add the install directory to the system PATH unless it is already there.
|
||||
ReadRegStr $0 HKLM "${ENVKEY}" "Path"
|
||||
${StrStr} $1 ";$0;" ";$INSTDIR;"
|
||||
${If} $1 == ""
|
||||
StrLen $2 $0
|
||||
${If} $2 > 900
|
||||
; NSIS strings are limited; rewriting a very long PATH could truncate it.
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "PATH is too long to update automatically. Add $INSTDIR to PATH manually."
|
||||
${Else}
|
||||
${If} $0 == ""
|
||||
WriteRegExpandStr HKLM "${ENVKEY}" "Path" "$INSTDIR"
|
||||
${Else}
|
||||
WriteRegExpandStr HKLM "${ENVKEY}" "Path" "$0;$INSTDIR"
|
||||
${EndIf}
|
||||
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
SetRegView 64
|
||||
Delete "$INSTDIR\relspec.exe"
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
RMDir "$INSTDIR"
|
||||
|
||||
; Remove the install directory from the system PATH.
|
||||
ReadRegStr $0 HKLM "${ENVKEY}" "Path"
|
||||
; Wrap in separators so every entry, including the first and last, matches.
|
||||
StrCpy $1 ";$0;"
|
||||
${UnStrRep} $1 "$1" ";$INSTDIR;" ";"
|
||||
StrCpy $2 $1 1
|
||||
${If} $2 == ";"
|
||||
StrCpy $1 $1 "" 1
|
||||
${EndIf}
|
||||
StrCpy $2 $1 1 -1
|
||||
${If} $2 == ";"
|
||||
StrCpy $1 $1 -1
|
||||
${EndIf}
|
||||
${If} $1 != $0
|
||||
WriteRegExpandStr HKLM "${ENVKEY}" "Path" "$1"
|
||||
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
|
||||
${EndIf}
|
||||
|
||||
DeleteRegKey HKLM "${REGKEY}"
|
||||
DeleteRegKey HKLM "Software\${APPNAME}"
|
||||
SectionEnd
|
||||
Reference in New Issue
Block a user