diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index c5b2cc0..574c57f 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 }}" diff --git a/Makefile b/Makefile index 10ecc5c..1cfd8e9 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/cmd/relspec/root.go b/cmd/relspec/root.go index 3004fc9..5e5bf14 100644 --- a/cmd/relspec/root.go +++ b/cmd/relspec/root.go @@ -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") diff --git a/cmd/relspec/update.go b/cmd/relspec/update.go new file mode 100644 index 0000000..7754f54 --- /dev/null +++ b/cmd/relspec/update.go @@ -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() +} diff --git a/cmd/relspec/update_test.go b/cmd/relspec/update_test.go new file mode 100644 index 0000000..5d9985a --- /dev/null +++ b/cmd/relspec/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/diff/diff_objects_test.go b/pkg/diff/diff_objects_test.go index 54fc012..36243e6 100644 --- a/pkg/diff/diff_objects_test.go +++ b/pkg/diff/diff_objects_test.go @@ -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) { diff --git a/pkg/models/sorting_test.go b/pkg/models/sorting_test.go index 2301069..0196f32 100644 --- a/pkg/models/sorting_test.go +++ b/pkg/models/sorting_test.go @@ -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)) diff --git a/pkg/readers/bun/helpers_test.go b/pkg/readers/bun/helpers_test.go index c486057..048de3c 100644 --- a/pkg/readers/bun/helpers_test.go +++ b/pkg/readers/bun/helpers_test.go @@ -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) { diff --git a/pkg/readers/gorm/helpers_test.go b/pkg/readers/gorm/helpers_test.go index fff36e8..2c378ca 100644 --- a/pkg/readers/gorm/helpers_test.go +++ b/pkg/readers/gorm/helpers_test.go @@ -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) { diff --git a/pkg/readers/prisma/reader_full_test.go b/pkg/readers/prisma/reader_full_test.go index 9933fa2..ca0463a 100644 --- a/pkg/readers/prisma/reader_full_test.go +++ b/pkg/readers/prisma/reader_full_test.go @@ -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") diff --git a/pkg/readers/typeorm/reader_full_test.go b/pkg/readers/typeorm/reader_full_test.go index cca38d9..d9f6bd4 100644 --- a/pkg/readers/typeorm/reader_full_test.go +++ b/pkg/readers/typeorm/reader_full_test.go @@ -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 { diff --git a/pkg/sqltypes/sql_array_types_roundtrip_test.go b/pkg/sqltypes/sql_array_types_roundtrip_test.go index 888f4b0..4f903e6 100644 --- a/pkg/sqltypes/sql_array_types_roundtrip_test.go +++ b/pkg/sqltypes/sql_array_types_roundtrip_test.go @@ -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 { diff --git a/pkg/ui/helpers_loadsave_test.go b/pkg/ui/helpers_loadsave_test.go index 764a785..548cabc 100644 --- a/pkg/ui/helpers_loadsave_test.go +++ b/pkg/ui/helpers_loadsave_test.go @@ -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() diff --git a/pkg/updatecheck/updatecheck.go b/pkg/updatecheck/updatecheck.go new file mode 100644 index 0000000..aa4778c --- /dev/null +++ b/pkg/updatecheck/updatecheck.go @@ -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 +} 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/pkg/writers/drizzle/writer_full_test.go b/pkg/writers/drizzle/writer_full_test.go index 00cd91f..acb2d40 100644 --- a/pkg/writers/drizzle/writer_full_test.go +++ b/pkg/writers/drizzle/writer_full_test.go @@ -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 { diff --git a/pkg/writers/prisma/types_test.go b/pkg/writers/prisma/types_test.go index 2153d5e..17e58d0 100644 --- a/pkg/writers/prisma/types_test.go +++ b/pkg/writers/prisma/types_test.go @@ -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++ { diff --git a/pkg/writers/prisma/writer_full_test.go b/pkg/writers/prisma/writer_full_test.go index 6ddcd9a..8be1700 100644 --- a/pkg/writers/prisma/writer_full_test.go +++ b/pkg/writers/prisma/writer_full_test.go @@ -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 { diff --git a/pkg/writers/typeorm/types_roundtrip_test.go b/pkg/writers/typeorm/types_roundtrip_test.go index 9f33f45..9d3a4c5 100644 --- a/pkg/writers/typeorm/types_roundtrip_test.go +++ b/pkg/writers/typeorm/types_roundtrip_test.go @@ -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") diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000..25865d1 --- /dev/null +++ b/windows/README.md @@ -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. diff --git a/windows/installer.nsi b/windows/installer.nsi new file mode 100644 index 0000000..7664886 --- /dev/null +++ b/windows/installer.nsi @@ -0,0 +1,111 @@ +; RelSpec Windows installer (NSIS) +; +; 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 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