diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 99d82f2..2ba1a3f 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -231,10 +231,10 @@ jobs: GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build \ -trimpath \ -ldflags "-X main.version=${PKGVER}" \ - -o pgtidy.exe \ + -o pgtidy-windows-amd64.exe \ ./cmd/pgtidy - makensis -DVERSION="${PKGVER}" -DSRC_EXE="$(pwd)/pgtidy.exe" windows/installer.nsi + makensis -DVERSION="${PKGVER}" -DEXE="$PWD/pgtidy-windows-amd64.exe" -DOUT="$PWD/pgtidy-setup-windows-amd64.exe" windows/installer.nsi - name: Upload to release run: | @@ -244,7 +244,7 @@ jobs: -H "Authorization: token ${GITHUB_TOKEN}") UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4 | sed 's/{[^}]*}//') [ -z "$UPLOAD_URL" ] && { echo "upload_url not found: $RELEASE"; exit 1; } - for f in windows/pgtidy-setup-*.exe; do + for f in pgtidy-setup-windows-amd64.exe; do echo "Uploading $(basename "$f")..." curl -s -X POST "${UPLOAD_URL}?name=$(basename "$f")" \ -H "Authorization: token ${GITHUB_TOKEN}" \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 0cfd094..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Vet - run: go vet ./... - - - name: Test - run: go test ./... - - - name: Format check - run: | - unformatted=$(gofmt -l .) - if [ -n "$unformatted" ]; then - echo "Files need gofmt:" - echo "$unformatted" - exit 1 - fi - - build-snapshot: - name: Build snapshot - runs-on: ubuntu-latest - needs: test - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - uses: goreleaser/goreleaser-action@v6 - with: - version: latest - args: release --snapshot --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload artifacts - uses: actions/upload-artifact@v3 - with: - name: dist-snapshot - path: dist/ - retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 7a1a52f..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - -jobs: - release: - name: Release - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - uses: goreleaser/goreleaser-action@v6 - with: - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} - - vscode-package: - name: VSCode Extension - runs-on: ubuntu-latest - needs: release - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: npm - cache-dependency-path: editors/vscode/package-lock.json - - - name: Set version from tag - working-directory: editors/vscode - run: npm pkg set version="${GITHUB_REF_NAME#v}" - - - name: Install and package - working-directory: editors/vscode - run: | - npm ci - npm run compile - npm run package - - - name: Upload to release - run: gh release upload ${{ github.ref_name }} editors/vscode/*.vsix - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - datagrip-package: - name: DataGrip Plugin - runs-on: ubuntu-latest - needs: release - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '21' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 - - - name: Set version from tag - working-directory: editors/datagrip - run: sed -i "s/^pluginVersion=.*/pluginVersion=${GITHUB_REF_NAME#v}/" gradle.properties - - - name: Build plugin - working-directory: editors/datagrip - run: ./gradlew buildPlugin - - - name: Upload to release - run: gh release upload ${{ github.ref_name }} editors/datagrip/build/distributions/*.zip - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile index 5b26b93..5ff1939 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ APP := pgtidy CMD := ./cmd/pgtidy DIST := dist -.PHONY: build test lint vet fmt clean release release-version snapshot vscode-compile vscode-package +.PHONY: installer-windows build test lint vet fmt clean release release-version snapshot vscode-compile vscode-package ## build: compile binary for the current platform build: @@ -25,6 +25,11 @@ lint: vet test -z "$$(gofmt -l .)" || (echo "gofmt needed:"; gofmt -l .; exit 1) golangci-lint run ./... +## installer-windows: build the Windows binary and NSIS installer (requires makensis) +installer-windows: + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$$(git describe --tags --abbrev=0 2>/dev/null || echo dev)" -o $(DIST)/pgtidy-windows-amd64.exe $(CMD) + makensis -DVERSION=$$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$$' || echo 0.0.0) -DEXE=$(CURDIR)/$(DIST)/pgtidy-windows-amd64.exe -DOUT=$(CURDIR)/$(DIST)/pgtidy-setup-windows-amd64.exe windows/installer.nsi + ## clean: remove build artifacts clean: rm -rf $(DIST) diff --git a/README.md b/README.md index 2c1eb07..0195c06 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ go install git.warky.dev/wdevs/pgtidy/cmd/pgtidy@latest Or download a pre-built binary from [Releases](https://git.warky.dev/wdevs/pgtidy/releases). +**Windows** — run `pgtidy-setup-windows-amd64.exe` from the release. It installs to +`%ProgramFiles%\PgTidy` and adds it to the system `PATH`. Update later with `pgtidy update`. + --- ## CLI @@ -27,6 +30,7 @@ pgtidy fmt [flags] [files...] Format SQL/PL-pgSQL (stdin if no files) pgtidy lint [flags] [files...] Lint SQL pgtidy config Print effective configuration pgtidy lsp Start LSP server (stdio) +pgtidy update [--check] [-y] Check for a newer release (installs it on Windows) pgtidy version ``` diff --git a/cmd/pgtidy/main.go b/cmd/pgtidy/main.go index 7396767..2dddd31 100644 --- a/cmd/pgtidy/main.go +++ b/cmd/pgtidy/main.go @@ -28,6 +28,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return cmdLsp(args[1:], stdin, stdout, stderr) case "config": return cmdConfig(args[1:], stdin, stdout, stderr) + case "update": + return cmdUpdate(args[1:], stdin, stdout, stderr) case "version", "--version", "-v": _, _ = fmt.Fprintf(stdout, "pgtidy %s\n", version) return 0 @@ -49,6 +51,7 @@ Usage: pgtidy lint [flags] [files...] Lint SQL (stdin if no files) pgtidy config Print effective configuration pgtidy lsp Start LSP server (stdio, for editors) + pgtidy update [--check] [-y] Check for a newer release (installs on Windows) pgtidy version Print version pgtidy help Show this help diff --git a/cmd/pgtidy/update.go b/cmd/pgtidy/update.go new file mode 100644 index 0000000..de1b1b6 --- /dev/null +++ b/cmd/pgtidy/update.go @@ -0,0 +1,158 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "git.warky.dev/wdevs/pgtidy/pkg/updatecheck" +) + +// windowsInstallerAsset is the release asset built by the NSIS installer step. +const windowsInstallerAsset = "pgtidy-setup-windows-amd64.exe" + +// cmdUpdate implements `pgtidy update`: check the latest release and, on +// Windows, offer to download and run the installer. +func cmdUpdate(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + var checkOnly, assumeYes bool + for _, a := range args { + switch a { + case "--check": + checkOnly = true + case "-y", "--yes": + assumeYes = true + case "-h", "--help": + _, _ = fmt.Fprint(stdout, `pgtidy update — check for a newer release + +Usage: + pgtidy update [--check] [-y|--yes] + + --check Only report whether an update is available + -y, --yes Update without prompting + +On Windows the installer is downloaded and started; elsewhere the release +page URL is shown. +`) + return 0 + default: + _, _ = fmt.Fprintf(stderr, "pgtidy update: unknown flag %q\n", a) + return 2 + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + err := runUpdate(ctx, updateOptions{ + current: version, + apiURL: updatecheck.DefaultAPIURL, + goos: runtime.GOOS, + checkOnly: checkOnly, + assumeYes: assumeYes, + in: stdin, + out: stdout, + install: downloadAndRunInstaller, + }) + if err != nil { + _, _ = fmt.Fprintf(stderr, "pgtidy update: %v\n", err) + return 1 + } + return 0 +} + +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, "PgTidy %s is up to date (latest release: %s)\n", o.current, rel.Tag) + return nil + } + + _, _ = fmt.Fprintf(o.out, "A newer PgTidy 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 pgtidy.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("", "pgtidy-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() +} diff --git a/cmd/pgtidy/update_test.go b/cmd/pgtidy/update_test.go new file mode 100644 index 0000000..5d9985a --- /dev/null +++ b/cmd/pgtidy/update_test.go @@ -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") + } +} diff --git a/pkg/updatecheck/updatecheck.go b/pkg/updatecheck/updatecheck.go new file mode 100644 index 0000000..62665ef --- /dev/null +++ b/pkg/updatecheck/updatecheck.go @@ -0,0 +1,115 @@ +// Package updatecheck looks up the latest PgTidy 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/pgtidy/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 +} diff --git a/pkg/updatecheck/updatecheck_test.go b/pkg/updatecheck/updatecheck_test.go new file mode 100644 index 0000000..5309b51 --- /dev/null +++ b/pkg/updatecheck/updatecheck_test.go @@ -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") + } +} diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000..5115cf7 --- /dev/null +++ b/windows/README.md @@ -0,0 +1,29 @@ +# Windows installer + +NSIS script: `installer.nsi`. Output: `dist/pgtidy-setup-windows-amd64.exe`. + +## Build + +``` +make installer-windows +``` + +Needs `makensis` (package `nsis`). CI builds and uploads it on release. + +## Installer + +- Installs `pgtidy.exe` to `%ProgramFiles%\PgTidy` (admin). +- Adds the install dir to the system `PATH`; removed on uninstall. +- Registers in Add/Remove Programs (supports silent `/S` uninstall). + +## Update check + +``` +pgtidy update # check, prompt, download + run installer (Windows) +pgtidy update --check # report only +pgtidy 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. diff --git a/windows/installer.nsi b/windows/installer.nsi index 0d9ad46..27e5c79 100644 --- a/windows/installer.nsi +++ b/windows/installer.nsi @@ -1,35 +1,43 @@ -; PgTidy Windows installer +; PgTidy Windows installer (NSIS) ; -; Build with (VERSION must match the release tag, without the leading "v"): -; makensis /DVERSION=1.2.3 /DSRC_EXE=path\to\pgtidy.exe windows\installer.nsi -; -; Installs pgtidy.exe into Program Files, adds the install dir to the -; machine-wide PATH, and on startup checks the git.warky.dev Gitea API for a -; newer release than the one being installed. +; Build: makensis -DVERSION=1.2.3 -DEXE= -DOUT= windows/installer.nsi +; Use absolute paths for EXE and OUT; relative ones resolve unpredictably. !ifndef VERSION !define VERSION "0.0.0" !endif -!ifndef SRC_EXE - !define SRC_EXE "..\dist\pgtidy.exe" +!ifndef OUT + !define OUT "pgtidy-setup-windows-amd64.exe" +!endif +!ifndef EXE + !define EXE "pgtidy-windows-amd64.exe" !endif -!define PRODUCT_NAME "PgTidy" -!define PRODUCT_PUBLISHER "Warky Devs" -!define PRODUCT_HOMEPAGE "https://git.warky.dev/wdevs/pgtidy" -!define RELEASES_API_URL "https://git.warky.dev/api/v1/repos/wdevs/pgtidy/releases/latest" -!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\PgTidy" -!define ENV_KEY 'HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"' +!define APPNAME "PgTidy" +!define REGKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\PgTidy" +!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}" +VIAddVersionKey "LegalCopyright" "Warky Devs" !include "MUI2.nsh" !include "LogicLib.nsh" - -Name "${PRODUCT_NAME} ${VERSION}" -OutFile "pgtidy-setup-${VERSION}.exe" -InstallDir "$PROGRAMFILES64\PgTidy" -InstallDirRegKey HKLM "${UNINST_KEY}" "InstallLocation" -RequestExecutionLevel admin -Unicode true +!include "StrFunc.nsh" +!include "WinMessages.nsh" +${StrStr} +${UnStrRep} !define MUI_ABORTWARNING !define MUI_ICON "..\assets\logo_128.ico" @@ -40,187 +48,72 @@ Unicode true !insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_FINISH - !insertmacro MUI_UNPAGE_CONFIRM !insertmacro MUI_UNPAGE_INSTFILES - !insertmacro MUI_LANGUAGE "English" -; --------------------------------------------------------------------------- -; Check the Gitea releases API for a newer version than the one we are about -; to install. Best-effort only: any failure (offline, API down, no -; PowerShell) is swallowed and the installer proceeds silently. -; --------------------------------------------------------------------------- -Function .onInit - StrCpy $1 "$TEMP\pgtidy-latest-version.txt" - Delete "$1" - - DetailPrint "Checking ${PRODUCT_HOMEPAGE} for a newer release..." - nsExec::ExecToLog 'powershell -NoProfile -NonInteractive -Command "try { $$r = Invoke-RestMethod -Uri ''${RELEASES_API_URL}'' -UseBasicParsing -TimeoutSec 5; $$r.tag_name | Out-File -Encoding ascii -NoNewline ''$1'' } catch { exit 0 }"' - - ${IfNot} ${FileExists} "$1" - Return - ${EndIf} - - FileOpen $2 "$1" r - FileRead $2 $3 - FileClose $2 - Delete "$1" - - StrCpy $4 $3 - ; Trim a leading "v" if the tag is e.g. "v1.2.3" - StrCpy $5 $4 1 - ${If} $5 == "v" - StrCpy $4 $4 "" 1 - ${EndIf} - - ${If} $4 != "" - ${AndIf} $4 != "${VERSION}" - MessageBox MB_YESNO|MB_ICONINFORMATION \ - "A newer version of PgTidy is available: $4 (this installer is ${VERSION}).$\n$\nOpen the releases page to download it now?$\n$\nChoosing No continues installing ${VERSION}." \ - IDNO +2 - ExecShell "open" "${PRODUCT_HOMEPAGE}/releases/latest" - ${EndIf} -FunctionEnd - -; --------------------------------------------------------------------------- -; Adds $INSTDIR to the machine PATH if it isn't already present. -; --------------------------------------------------------------------------- -Function AddToPath - ReadRegStr $0 ${ENV_KEY} "Path" - Push "$0" - Push "$INSTDIR" - Call StrContains - Pop $1 - ${If} $1 == "" - ${If} $0 == "" - StrCpy $0 "$INSTDIR" - ${Else} - StrCpy $0 "$0;$INSTDIR" - ${EndIf} - WriteRegExpandStr ${ENV_KEY} "Path" "$0" - SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000 - ${EndIf} -FunctionEnd - -; --------------------------------------------------------------------------- -; Removes $INSTDIR from the machine PATH. -; --------------------------------------------------------------------------- -Function un.RemoveFromPath - ReadRegStr $0 ${ENV_KEY} "Path" - Push "$0;" - Push "$INSTDIR;" - Push "" - Call un.StrReplace - Pop $0 - Push "$0" - Push "$INSTDIR" - Push "" - Call un.StrReplace - Pop $0 - ; Drop a trailing separator left behind by the replacements above. - StrCpy $1 $0 1 -1 - ${If} $1 == ";" - StrCpy $0 $0 -1 - ${EndIf} - WriteRegExpandStr ${ENV_KEY} "Path" "$0" - SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000 -FunctionEnd - -; Returns the index of needle in haystack via $R0, or "" if absent. -; Push haystack, Push needle -> Pop result -Function StrContains - Exch $R1 ; needle - Exch - Exch $R2 ; haystack - Push $R3 - Push $R4 - Push $R5 - StrLen $R3 $R1 - StrCpy $R4 0 - ${Do} - StrCpy $R5 $R2 $R3 $R4 - ${If} $R5 == $R1 - StrCpy $R0 $R4 - ${ExitDo} - ${EndIf} - ${If} $R5 == "" - StrCpy $R0 "" - ${ExitDo} - ${EndIf} - IntOp $R4 $R4 + 1 - ${Loop} - Pop $R5 - Pop $R4 - Pop $R3 - Pop $R2 - Pop $R1 - Push $R0 - Exch - Pop $R0 -FunctionEnd - -; Push string, Push search, Push replace -> Pop result -Function un.StrReplace - Exch $R0 ; replace - Exch - Exch $R1 ; search - Exch 2 - Exch $R2 ; string - Push $R3 - Push $R4 - Push $R5 - Push $R6 - StrLen $R3 $R1 - StrCpy $R4 "" - ${Do} - StrCpy $R5 $R2 $R3 - ${If} $R5 == $R1 - StrCpy $R4 "$R4$R0" - StrCpy $R2 $R2 "" $R3 - ${ElseIf} $R2 == "" - ${ExitDo} - ${Else} - StrCpy $R6 $R2 1 - StrCpy $R4 "$R4$R6" - StrCpy $R2 $R2 "" 1 - ${EndIf} - ${Loop} - Pop $R6 - Pop $R5 - Pop $R4 - Pop $R3 - Pop $R2 - Pop $R1 - Pop $R0 - Push $R4 -FunctionEnd - -Section "PgTidy" SEC_MAIN - SectionIn RO +Section "Install" + SetRegView 64 SetOutPath "$INSTDIR" - File "${SRC_EXE}" + File "/oname=pgtidy.exe" "${EXE}" File "..\LICENSE" - - Call AddToPath - - WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT_NAME}" - WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}" - WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${PRODUCT_PUBLISHER}" - WriteRegStr HKLM "${UNINST_KEY}" "InstallLocation" "$INSTDIR" - WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$INSTDIR\uninstall.exe" - WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$INSTDIR\uninstall.exe /S" - WriteRegDWORD HKLM "${UNINST_KEY}" "NoModify" 1 - WriteRegDWORD HKLM "${UNINST_KEY}" "NoRepair" 1 - 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\pgtidy.exe" + WriteRegStr HKLM "${REGKEY}" "InstallLocation" "$INSTDIR" + WriteRegStr HKLM "${REGKEY}" "UninstallString" '"$INSTDIR\uninstall.exe"' + WriteRegStr HKLM "${REGKEY}" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S' + 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" - Call un.RemoveFromPath + SetRegView 64 Delete "$INSTDIR\pgtidy.exe" Delete "$INSTDIR\LICENSE" Delete "$INSTDIR\uninstall.exe" RMDir "$INSTDIR" - DeleteRegKey HKLM "${UNINST_KEY}" + + ; 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