Merge pull request 'feat: NSIS Windows installer and update check (#32)' (#56) from issue-32-nsis-installer into master

Reviewed-on: #56
This commit was merged in pull request #56.
This commit is contained in:
2026-10-03 21:19:58 +00:00
21 changed files with 767 additions and 60 deletions
+7
View File
@@ -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 }}"
+6 -1
View File
@@ -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
+1
View File
@@ -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")
+148
View File
@@ -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()
}
+110
View File
@@ -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")
}
}
+16 -8
View File
@@ -23,14 +23,22 @@ func TestCompareSchemaDetails(t *testing.T) {
mutate func(*models.Schema)
check func(*SchemaChange) bool
}{
{"table added", func(s *models.Schema) { s.Tables = append(s.Tables, models.InitTable("u", "public")) },
func(c *SchemaChange) bool { return c.Tables != nil && len(c.Tables.Extra) == 1 }},
{"view added", func(s *models.Schema) { s.Views = []*models.View{models.InitView("v", "public")} },
func(c *SchemaChange) bool { return c.Views != nil && len(c.Views.Extra) == 1 }},
{"sequence added", func(s *models.Schema) { s.Sequences = []*models.Sequence{models.InitSequence("sq", "public")} },
func(c *SchemaChange) bool { return c.Sequences != nil && len(c.Sequences.Extra) == 1 }},
{"script added", func(s *models.Schema) { s.Scripts = []*models.Script{models.InitScript("sc")} },
func(c *SchemaChange) bool { return c.Scripts != nil && len(c.Scripts.Extra) == 1 }},
{
"table added", func(s *models.Schema) { s.Tables = append(s.Tables, models.InitTable("u", "public")) },
func(c *SchemaChange) bool { return c.Tables != nil && len(c.Tables.Extra) == 1 },
},
{
"view added", func(s *models.Schema) { s.Views = []*models.View{models.InitView("v", "public")} },
func(c *SchemaChange) bool { return c.Views != nil && len(c.Views.Extra) == 1 },
},
{
"sequence added", func(s *models.Schema) { s.Sequences = []*models.Sequence{models.InitSequence("sq", "public")} },
func(c *SchemaChange) bool { return c.Sequences != nil && len(c.Sequences.Extra) == 1 },
},
{
"script added", func(s *models.Schema) { s.Scripts = []*models.Script{models.InitScript("sc")} },
func(c *SchemaChange) bool { return c.Scripts != nil && len(c.Scripts.Extra) == 1 },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+2 -1
View File
@@ -28,7 +28,8 @@ func checkNames(t *testing.T, label string, got, want []string) {
// runSortSuite exercises a by-name and by-sequence sorter pair over the shared fixture.
func runSortSuite[T any](t *testing.T, build func(sortCase) T, name func(T) string,
byName func([]T, bool) error, bySeq func([]T, bool) error) {
byName, bySeq func([]T, bool) error,
) {
t.Helper()
mk := func() []T {
out := make([]T, 0, len(sortFixture))
+21 -9
View File
@@ -23,15 +23,27 @@ func mustExpr(t *testing.T, src string) ast.Expr {
func TestGoTypeToSQL(t *testing.T) {
r := newTestReader()
tests := []struct{ src, want string }{
{"int", "integer"}, {"int32", "integer"}, {"int64", "bigint"},
{"string", "text"}, {"bool", "boolean"}, {"float32", "real"},
{"float64", "double precision"}, {"uint8", "text"},
{"time.Time", "timestamp"}, {"time.Duration", "text"},
{"sql_types.SqlString", "text"}, {"sql_types.SqlInt", "integer"},
{"sql_types.SqlInt64", "bigint"}, {"sql_types.SqlFloat", "double precision"},
{"sql_types.SqlBool", "boolean"}, {"sql_types.SqlTime", "timestamp"},
{"sql_types.Other", "text"}, {"other.Thing", "text"},
{"*int64", "bigint"}, {"*time.Time", "timestamp"}, {"[]byte", "text"},
{"int", "integer"},
{"int32", "integer"},
{"int64", "bigint"},
{"string", "text"},
{"bool", "boolean"},
{"float32", "real"},
{"float64", "double precision"},
{"uint8", "text"},
{"time.Time", "timestamp"},
{"time.Duration", "text"},
{"sql_types.SqlString", "text"},
{"sql_types.SqlInt", "integer"},
{"sql_types.SqlInt64", "bigint"},
{"sql_types.SqlFloat", "double precision"},
{"sql_types.SqlBool", "boolean"},
{"sql_types.SqlTime", "timestamp"},
{"sql_types.Other", "text"},
{"other.Thing", "text"},
{"*int64", "bigint"},
{"*time.Time", "timestamp"},
{"[]byte", "text"},
}
for _, tt := range tests {
t.Run(tt.src, func(t *testing.T) {
+21 -9
View File
@@ -23,15 +23,27 @@ func mustExpr(t *testing.T, src string) ast.Expr {
func TestGoTypeToSQL(t *testing.T) {
r := newTestReader()
tests := []struct{ src, want string }{
{"int", "integer"}, {"int32", "integer"}, {"int64", "bigint"},
{"string", "text"}, {"bool", "boolean"}, {"float32", "real"},
{"float64", "double precision"}, {"uint8", "text"},
{"time.Time", "timestamp"}, {"time.Duration", "text"},
{"sql_types.SqlString", "text"}, {"sql_types.SqlInt", "integer"},
{"sql_types.SqlInt64", "bigint"}, {"sql_types.SqlFloat", "double precision"},
{"sql_types.SqlBool", "boolean"}, {"sql_types.SqlTime", "timestamp"},
{"sql_types.Other", "text"}, {"other.Thing", "text"},
{"*int64", "bigint"}, {"*time.Time", "timestamp"}, {"[]byte", "text"},
{"int", "integer"},
{"int32", "integer"},
{"int64", "bigint"},
{"string", "text"},
{"bool", "boolean"},
{"float32", "real"},
{"float64", "double precision"},
{"uint8", "text"},
{"time.Time", "timestamp"},
{"time.Duration", "text"},
{"sql_types.SqlString", "text"},
{"sql_types.SqlInt", "integer"},
{"sql_types.SqlInt64", "bigint"},
{"sql_types.SqlFloat", "double precision"},
{"sql_types.SqlBool", "boolean"},
{"sql_types.SqlTime", "timestamp"},
{"sql_types.Other", "text"},
{"other.Thing", "text"},
{"*int64", "bigint"},
{"*time.Time", "timestamp"},
{"[]byte", "text"},
}
for _, tt := range tests {
t.Run(tt.src, func(t *testing.T) {
+6 -3
View File
@@ -224,9 +224,12 @@ func TestParseDatasourceProviders(t *testing.T) {
provider string
want models.DatabaseType
}{
{`"postgresql"`, models.PostgresqlDatabaseType}, {`"postgres"`, models.PostgresqlDatabaseType},
{`"mysql"`, "mysql"}, {`"sqlite"`, models.SqlLiteDatabaseType},
{`"sqlserver"`, models.MSSQLDatabaseType}, {`"cockroachdb"`, models.PostgresqlDatabaseType},
{`"postgresql"`, models.PostgresqlDatabaseType},
{`"postgres"`, models.PostgresqlDatabaseType},
{`"mysql"`, "mysql"},
{`"sqlite"`, models.SqlLiteDatabaseType},
{`"sqlserver"`, models.MSSQLDatabaseType},
{`"cockroachdb"`, models.PostgresqlDatabaseType},
}
for _, tt := range tests {
db := models.InitDatabase("d")
+8 -2
View File
@@ -321,8 +321,14 @@ func TestMatchDecorator(t *testing.T) {
func TestTypeScriptTypeToSQL(t *testing.T) {
r := &Reader{}
tests := []struct{ in, want string }{
{"string", "text"}, {"number", "integer"}, {"boolean", "boolean"}, {"Date", "timestamp"},
{"any", "jsonb"}, {"string[]", "text"}, {"string | null", "text"}, {"Unknown", "text"},
{"string", "text"},
{"number", "integer"},
{"boolean", "boolean"},
{"Date", "timestamp"},
{"any", "jsonb"},
{"string[]", "text"},
{"string | null", "text"},
{"Unknown", "text"},
}
for _, tt := range tests {
if got := r.typeScriptTypeToSQL(tt.in); got != tt.want {
@@ -36,7 +36,7 @@ type wrapped[T any] struct {
// arrayRoundTrip runs the full Scan/Value/JSON/YAML/XML contract for one array type.
// badScan is a literal the type's Scan must reject ("" skips the check).
func arrayRoundTrip[T any, P arrayPtr[T]](t *testing.T, sample T, null T, badScan string) {
func arrayRoundTrip[T any, P arrayPtr[T]](t *testing.T, sample, null T, badScan string) {
t.Helper()
sv, ok := any(sample).(arrayValue)
if !ok {
+21 -7
View File
@@ -149,10 +149,17 @@ func TestObjectKind_CreateIndexFromForm(t *testing.T) {
func TestLoadDatabase(t *testing.T) {
for _, tt := range []struct{ format, path string }{
{"dbml", "dbml/simple.dbml"}, {"json", "json/database.json"}, {"yaml", "yaml/database.yaml"},
{"drawdb", "drawdb/simple.json"}, {"dctx", "dctx/p1.dctx"}, {"graphql", "graphql/simple.graphql"},
{"prisma", "prisma/example.prisma"}, {"typeorm", "typeorm/example.ts"},
{"drizzle", "drizzle/schema.ts"}, {"gorm", "gorm/simple.go"}, {"bun", "bun/simple.go"},
{"dbml", "dbml/simple.dbml"},
{"json", "json/database.json"},
{"yaml", "yaml/database.yaml"},
{"drawdb", "drawdb/simple.json"},
{"dctx", "dctx/p1.dctx"},
{"graphql", "graphql/simple.graphql"},
{"prisma", "prisma/example.prisma"},
{"typeorm", "typeorm/example.ts"},
{"drizzle", "drizzle/schema.ts"},
{"gorm", "gorm/simple.go"},
{"bun", "bun/simple.go"},
} {
t.Run(tt.format, func(t *testing.T) {
se := newUIEditor()
@@ -200,9 +207,16 @@ func TestCreateNewDatabase(t *testing.T) {
func TestSaveDatabase(t *testing.T) {
for _, tt := range []struct{ format, file string }{
{"json", "o.json"}, {"yaml", "o.yaml"}, {"dbml", "o.dbml"}, {"drawdb", "o.drawdb.json"},
{"graphql", "o.graphql"}, {"prisma", "o.prisma"}, {"typeorm", "o.ts"}, {"drizzle", "d.ts"},
{"gorm", "g.go"}, {"bun", "b.go"},
{"json", "o.json"},
{"yaml", "o.yaml"},
{"dbml", "o.dbml"},
{"drawdb", "o.drawdb.json"},
{"graphql", "o.graphql"},
{"prisma", "o.prisma"},
{"typeorm", "o.ts"},
{"drizzle", "d.ts"},
{"gorm", "g.go"},
{"bun", "b.go"},
} {
t.Run(tt.format, func(t *testing.T) {
se := newUIEditor()
+115
View File
@@ -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
}
+94
View File
@@ -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")
}
}
+13 -5
View File
@@ -219,11 +219,19 @@ func TestFormatDefaultValue(t *testing.T) {
in any
want string
}{
{"now()", "sql`now()`"}, {"CURRENT_TIMESTAMP", "sql`now()`"},
{"gen_random_uuid()", "sql`gen_random_uuid()`"}, {"uuid_generate_v4()", "sql`gen_random_uuid()`"},
{"42", "42"}, {"-1.5", "-1.5"}, {"it's", `'it\'s'`}, {"plain", "'plain'"},
{true, "true"}, {false, "false"},
{7, "7"}, {int64(8), "8"}, {2.5, "2.5"},
{"now()", "sql`now()`"},
{"CURRENT_TIMESTAMP", "sql`now()`"},
{"gen_random_uuid()", "sql`gen_random_uuid()`"},
{"uuid_generate_v4()", "sql`gen_random_uuid()`"},
{"42", "42"},
{"-1.5", "-1.5"},
{"it's", `'it\'s'`},
{"plain", "'plain'"},
{true, "true"},
{false, "false"},
{7, "7"},
{int64(8), "8"},
{2.5, "2.5"},
}
for _, tt := range tests {
if got := tm.formatDefaultValue(tt.in); got != tt.want {
+24 -9
View File
@@ -13,15 +13,30 @@ func TestSQLTypeToPrisma(t *testing.T) {
schema.Enums = append(schema.Enums, &models.Enum{Name: "Role", Values: []string{"A"}})
tests := []struct{ in, want string }{
{"text", "String"}, {"varchar(255)", "String"}, {"character varying", "String"}, {"char(1)", "String"},
{"boolean", "Boolean"}, {"bool", "Boolean"},
{"integer", "Int"}, {"int", "Int"}, {"int4", "Int"},
{"bigint", "BigInt"}, {"int8", "BigInt"}, {"BIGINT", "BigInt"},
{"double precision", "Float"}, {"float8", "Float"},
{"numeric(10,2)", "Decimal"}, {"decimal", "Decimal"},
{"timestamp", "DateTime"}, {"timestamptz", "DateTime"}, {"date", "DateTime"},
{"jsonb", "Json"}, {"json", "Json"}, {"bytea", "Bytes"},
{"role", "Role"}, {"unknown_type", "String"},
{"text", "String"},
{"varchar(255)", "String"},
{"character varying", "String"},
{"char(1)", "String"},
{"boolean", "Boolean"},
{"bool", "Boolean"},
{"integer", "Int"},
{"int", "Int"},
{"int4", "Int"},
{"bigint", "BigInt"},
{"int8", "BigInt"},
{"BIGINT", "BigInt"},
{"double precision", "Float"},
{"float8", "Float"},
{"numeric(10,2)", "Decimal"},
{"decimal", "Decimal"},
{"timestamp", "DateTime"},
{"timestamptz", "DateTime"},
{"date", "DateTime"},
{"jsonb", "Json"},
{"json", "Json"},
{"bytea", "Bytes"},
{"role", "Role"},
{"unknown_type", "String"},
}
// Repeat: the mapping used to depend on map iteration order.
for i := 0; i < 50; i++ {
+9 -3
View File
@@ -140,9 +140,15 @@ func TestFormatDefaultValue(t *testing.T) {
in any
want string
}{
{"now()", "now()"}, {"gen_random_uuid()", "uuid()"}, {"uuid_generate_v4()", "uuid()"},
{"hello", `"hello"`}, {true, "true"}, {false, "false"},
{42, "42"}, {int64(7), "7"}, {1.5, "1.5"},
{"now()", "now()"},
{"gen_random_uuid()", "uuid()"},
{"uuid_generate_v4()", "uuid()"},
{"hello", `"hello"`},
{true, "true"},
{false, "false"},
{42, "42"},
{int64(7), "7"},
{1.5, "1.5"},
}
for _, tt := range tests {
if got := w.formatDefaultValue(tt.in); got != tt.want {
+4 -2
View File
@@ -11,8 +11,10 @@ import (
)
func TestColumnTypesSurviveRoundTrip(t *testing.T) {
types := []string{"integer", "boolean", "timestamp", "text", "uuid", "jsonb", "bigint",
"varchar(255)", "char(3)", "numeric(10,2)", "timestamptz", "smallint", "date", "double precision"}
types := []string{
"integer", "boolean", "timestamp", "text", "uuid", "jsonb", "bigint",
"varchar(255)", "char(3)", "numeric(10,2)", "timestamptz", "smallint", "date", "double precision",
}
tbl := models.InitTable("things", "public")
id := models.InitColumn("id", "things", "public")
+29
View File
@@ -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.
+111
View File
@@ -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