diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..251f882 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.20' + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v4 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..d7968db --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,78 @@ +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - id: memex + main: ./cmd/memex + binary: memex + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + env: + - CGO_ENABLED=0 + ldflags: + - -s -w -X main.version={{.Version}} + + - id: memexd + main: ./cmd/memexd + binary: memexd + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + env: + - CGO_ENABLED=0 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - id: memex-archive + builds: + - memex + - memexd + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + format_overrides: + - goos: windows + format: zip + files: + - README.md + - LICENSE + - docs/* + +checksum: + name_template: 'checksums.txt' + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^ci:' + - Merge pull request + - Merge branch + +brews: + - repository: + owner: systemshift + name: homebrew-memex + homepage: "https://github.com/systemshift/memex" + description: "Graph-oriented data management tool" + install: | + bin.install "memex" + bin.install "memexd" diff --git a/README.md b/README.md index 260f2cf..063cc76 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,39 @@ Memex is a graph-oriented data management tool. ## Installation +### From Pre-built Binaries + +Download the latest pre-built binaries for your platform from the [GitHub Releases](https://github.com/systemshift/memex/releases) page. + +#### Linux and macOS +```bash +# Download and extract the archive +tar xzf memex__.tar.gz + +# Move binaries to your PATH +sudo mv memex /usr/local/bin/ +sudo mv memexd /usr/local/bin/ + +# Verify installation +memex --version +memexd --version +``` + +#### Windows +1. Download the ZIP archive for Windows +2. Extract the contents +3. Add the extracted directory to your PATH +4. Verify installation by running `memex --version` and `memexd --version` + +### Using Homebrew (macOS) + +```bash +# Install both memex and memexd +brew install systemshift/memex/memex +``` + +### Build from Source + ```bash # Build the CLI tool go build -o ~/bin/memex ./cmd/memex @@ -40,6 +73,9 @@ memex connect myrepo.mx # Show repository status memex status +# Show version information +memex version + # Add a file memex add document.txt diff --git a/cmd/memex/main.go b/cmd/memex/main.go index 9feff90..93ae445 100644 --- a/cmd/memex/main.go +++ b/cmd/memex/main.go @@ -6,9 +6,18 @@ import ( "os" "path/filepath" - "github.com/systemshift/memex/pkg/memex" + "github.com/systemshift/memex/internal/memex" ) +var ( + showVersion = flag.Bool("version", false, "Show version information") +) + +func printVersion() { + fmt.Println(memex.BuildInfo()) + os.Exit(0) +} + func usage() { fmt.Printf(`Usage: %s [arguments] @@ -21,6 +30,7 @@ Built-in Commands: link Create a link between nodes links Show links for a node status Show repository status + version Show version information export Export repository to tar archive import Import repository from tar archive @@ -47,6 +57,10 @@ func main() { flag.Usage = usage flag.Parse() + if *showVersion { + printVersion() + } + cmds := memex.NewCommands() defer cmds.Close() @@ -138,6 +152,15 @@ func main() { } err = cmds.Status() + case "version": + if err := cmds.AutoConnect(); err == nil { + // If connected to a repo, show its version too + err = cmds.ShowVersion() + } else { + // Just show memex version + fmt.Println(memex.BuildInfo()) + } + case "export": if len(args) != 1 { usage() diff --git a/cmd/memexd/main.go b/cmd/memexd/main.go index 94ab81b..bb05aee 100644 --- a/cmd/memexd/main.go +++ b/cmd/memexd/main.go @@ -9,13 +9,22 @@ import ( "net/http" "os" "path/filepath" + "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/systemshift/memex/internal/memex" "github.com/systemshift/memex/internal/memex/core" "github.com/systemshift/memex/internal/memex/repository" ) +// VersionResponse represents version information +type VersionResponse struct { + Version string `json:"version"` + Commit string `json:"commit"` + BuildDate string `json:"buildDate"` +} + // Server handles HTTP requests and manages the repository type Server struct { repo core.Repository @@ -51,8 +60,14 @@ func main() { // Parse command line flags addr := flag.String("addr", ":3000", "HTTP service address") repoPath := flag.String("repo", "", "Repository path") + showVersion := flag.Bool("version", false, "Show version information") flag.Parse() + if *showVersion { + fmt.Println(memex.BuildInfo()) + os.Exit(0) + } + if *repoPath == "" { log.Fatal("Repository path required") } @@ -87,6 +102,7 @@ func main() { // Routes r.Get("/", server.handleIndex) r.Route("/api", func(r chi.Router) { + r.Get("/version", server.handleVersion) r.Get("/graph", server.handleGraph) r.Get("/nodes/{id}", server.handleGetNode) r.Get("/nodes/{id}/content", server.handleGetContent) @@ -242,3 +258,23 @@ func (s *Server) handleGetContent(w http.ResponseWriter, r *http.Request) { w.Write(content) } + +// handleVersion returns version information +func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { + info := strings.Split(memex.BuildInfo(), "\n") + version := strings.TrimPrefix(info[0], "Version: ") + commit := strings.TrimPrefix(info[1], "Commit: ") + date := strings.TrimPrefix(info[2], "Build Date: ") + + response := VersionResponse{ + Version: version, + Commit: commit, + BuildDate: date, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError) + return + } +} diff --git a/docs/VERSION.md b/docs/VERSION.md new file mode 100644 index 0000000..190e11c --- /dev/null +++ b/docs/VERSION.md @@ -0,0 +1,74 @@ +# Version Compatibility Guide + +Memex uses semantic versioning for both the application and repository format to ensure safe upgrades and compatibility. + +## Version Types + +### Application Version +- The version of the memex binaries (e.g., 1.2.3) +- Follows semantic versioning (MAJOR.MINOR.PATCH) +- Displayed with `memex --version` + +### Repository Format Version +- The version of the .mx file format (e.g., 1.0) +- Uses MAJOR.MINOR format +- Stored in repository header +- Checked when opening repositories + +## Compatibility Rules + +When opening a repository, Memex checks: +1. Repository format version compatibility +2. Records which version of memex created the repository + +Version compatibility rules: +- Major version must match exactly (e.g., 1.x can't open 2.x repositories) +- Minor version of the repository must be <= current version +- The version that created a repository is stored in its header + +## Version Information + +Use `memex version` to check: +- Current memex version +- Repository format version +- Which memex version created the repository + +Example output: +``` +Memex Version: 1.2.3 +Commit: abc123 +Build Date: 2024-01-01 + +Repository Format Version: 1.0 +Created by Memex Version: 1.2.0 +``` + +## Version History + +### Repository Format Versions + +- 1.0: Initial stable format + - Content-addressable storage + - DAG structure + - Transaction log + - Basic metadata + +Future versions will maintain backward compatibility within the same major version. + +## Upgrading Repositories + +When a new version of Memex is released: + +1. If only PATCH version changes: + - No action needed + - Full compatibility maintained + +2. If MINOR version changes: + - Repository format is compatible + - New features may be available + - No migration needed + +3. If MAJOR version changes: + - Repository format may be incompatible + - Migration tool will be provided + - Check release notes for upgrade path diff --git a/internal/memex/commands_wrapper.go b/internal/memex/commands_wrapper.go new file mode 100644 index 0000000..cdbad1b --- /dev/null +++ b/internal/memex/commands_wrapper.go @@ -0,0 +1,121 @@ +package memex + +import ( + "fmt" + "strings" + + "github.com/systemshift/memex/internal/memex/repository" +) + +// Commands wraps the command functions into a struct interface +type Commands struct{} + +// NewCommands creates a new Commands instance +func NewCommands() *Commands { + return &Commands{} +} + +// Close closes any open resources +func (c *Commands) Close() error { + return CloseRepository() +} + +// AutoConnect attempts to connect to a repository in the current directory +func (c *Commands) AutoConnect() error { + return OpenRepository() +} + +// Init initializes a new repository +func (c *Commands) Init(name string) error { + return InitCommand(name) +} + +// Connect connects to an existing repository +func (c *Commands) Connect(path string) error { + return ConnectCommand(path) +} + +// Add adds a file to the repository +func (c *Commands) Add(path string) error { + return AddCommand(path) +} + +// Delete removes a node +func (c *Commands) Delete(id string) error { + return DeleteCommand(id) +} + +// Edit opens the editor for a new note +func (c *Commands) Edit() error { + return EditCommand() +} + +// Link creates a link between nodes +func (c *Commands) Link(source, target, linkType, note string) error { + if note == "" { + return LinkCommand(source, target, linkType) + } + return LinkCommand(source, target, linkType, note) +} + +// Links shows links for a node +func (c *Commands) Links(id string) error { + return LinksCommand(id) +} + +// Status shows repository status +func (c *Commands) Status() error { + return StatusCommand() +} + +// Export exports the repository +func (c *Commands) Export(path string) error { + return ExportCommand(path) +} + +// ImportOptions defines options for importing +type ImportOptions struct { + OnConflict string + Merge bool + Prefix string +} + +// ShowVersion shows version information for memex and the repository +func (c *Commands) ShowVersion() error { + repo, err := GetRepository() + if err != nil { + return err + } + + // Show memex version + fmt.Println("Memex Version:", BuildInfo()) + + // Get repository version info + repoInfo := repo.(*repository.Repository).GetVersionInfo() + + // Show repository version + fmt.Printf("\nRepository Format Version: %d.%d\n", + repoInfo.FormatVersion, + repoInfo.FormatMinor) + + // Show version that created the repository + repoVersion := strings.TrimRight(repoInfo.MemexVersion, "\x00") + fmt.Printf("Created by Memex Version: %s\n", repoVersion) + + return nil +} + +// Import imports content from a tar archive +func (c *Commands) Import(path string, opts ImportOptions) error { + args := []string{path} + if opts.OnConflict != "" { + args = append(args, "--on-conflict", opts.OnConflict) + } + if opts.Merge { + args = append(args, "--merge") + } + if opts.Prefix != "" { + args = append(args, "--prefix", opts.Prefix) + } + return ImportCommand(args...) +} diff --git a/internal/memex/core/version.go b/internal/memex/core/version.go new file mode 100644 index 0000000..7797581 --- /dev/null +++ b/internal/memex/core/version.go @@ -0,0 +1,60 @@ +package core + +import ( + "fmt" + "strconv" + "strings" +) + +// RepositoryVersion represents the version of a repository +type RepositoryVersion struct { + Major uint8 // Major version for incompatible changes + Minor uint8 // Minor version for backwards-compatible changes +} + +// String returns the string representation of the version +func (v RepositoryVersion) String() string { + return fmt.Sprintf("%d.%d", v.Major, v.Minor) +} + +// ParseVersion parses a version string into a RepositoryVersion +func ParseVersion(version string) (RepositoryVersion, error) { + parts := strings.Split(version, ".") + if len(parts) != 2 { + return RepositoryVersion{}, fmt.Errorf("invalid version format: %s", version) + } + + major, err := strconv.ParseUint(parts[0], 10, 8) + if err != nil { + return RepositoryVersion{}, fmt.Errorf("invalid major version: %s", parts[0]) + } + + minor, err := strconv.ParseUint(parts[1], 10, 8) + if err != nil { + return RepositoryVersion{}, fmt.Errorf("invalid minor version: %s", parts[1]) + } + + return RepositoryVersion{ + Major: uint8(major), + Minor: uint8(minor), + }, nil +} + +// IsCompatible checks if the given version is compatible with this version +func (v RepositoryVersion) IsCompatible(other RepositoryVersion) bool { + // Major version must match exactly + // Minor version of repository must be <= current version + return v.Major == other.Major && v.Minor >= other.Minor +} + +// CurrentVersion is the current repository format version +var CurrentVersion = RepositoryVersion{ + Major: 1, + Minor: 0, +} + +// MinimumVersion is the minimum supported repository format version +var MinimumVersion = RepositoryVersion{ + Major: 1, + Minor: 0, +} diff --git a/internal/memex/repository/repository.go b/internal/memex/repository/repository.go index 0dd56e6..f503b4d 100644 --- a/internal/memex/repository/repository.go +++ b/internal/memex/repository/repository.go @@ -21,15 +21,17 @@ const MagicNumber = "MEMEX01" // Header represents the .mx file header (128 bytes) type Header struct { - Magic [7]byte // "MEMEX01" - Version uint8 // Format version - Created int64 // Creation timestamp (Unix seconds) - Modified int64 // Last modified timestamp (Unix seconds) - NodeCount uint32 // Number of nodes - EdgeCount uint32 // Number of edges - NodeIndex uint64 // Offset to node index - EdgeIndex uint64 // Offset to edge index - Reserved [64]byte // Future use + Magic [7]byte // "MEMEX01" + FormatVersion uint8 // Repository format version (major) + FormatMinor uint8 // Repository format version (minor) + MemexVersion [32]byte // Memex version that created the repository + Created int64 // Creation timestamp (Unix seconds) + Modified int64 // Last modified timestamp (Unix seconds) + NodeCount uint32 // Number of nodes + EdgeCount uint32 // Number of edges + NodeIndex uint64 // Offset to node index + EdgeIndex uint64 // Offset to edge index + Reserved [31]byte // Future use } // Repository represents a content repository @@ -59,13 +61,15 @@ func Create(path string) (*Repository, error) { // Initialize header now := time.Now().UTC().Unix() header := Header{ - Version: 1, - Created: now, - Modified: now, - NodeCount: 0, - EdgeCount: 0, + FormatVersion: core.CurrentVersion.Major, + FormatMinor: core.CurrentVersion.Minor, + Created: now, + Modified: now, + NodeCount: 0, + EdgeCount: 0, } copy(header.Magic[:], MagicNumber) + copy(header.MemexVersion[:], []byte(core.CurrentVersion.String())) // Write header if err := binary.Write(file, binary.LittleEndian, &header); err != nil { @@ -117,12 +121,24 @@ func Open(path string) (*Repository, error) { return nil, fmt.Errorf("reading header: %w", err) } - // Verify magic number + // Verify magic number and version compatibility if string(header.Magic[:]) != MagicNumber { file.Close() return nil, fmt.Errorf("invalid repository file") } + // Check format version compatibility + repoVersion := core.RepositoryVersion{ + Major: header.FormatVersion, + Minor: header.FormatMinor, + } + + if !core.CurrentVersion.IsCompatible(repoVersion) { + file.Close() + return nil, fmt.Errorf("incompatible repository version %s (current version: %s)", + repoVersion.String(), core.CurrentVersion.String()) + } + // Create repository instance repo := &Repository{ path: path, diff --git a/internal/memex/repository/version.go b/internal/memex/repository/version.go new file mode 100644 index 0000000..4d5e444 --- /dev/null +++ b/internal/memex/repository/version.go @@ -0,0 +1,17 @@ +package repository + +// VersionInfo contains version information for a repository +type VersionInfo struct { + FormatVersion uint8 + FormatMinor uint8 + MemexVersion string +} + +// GetVersionInfo returns version information for the repository +func (r *Repository) GetVersionInfo() VersionInfo { + return VersionInfo{ + FormatVersion: r.header.FormatVersion, + FormatMinor: r.header.FormatMinor, + MemexVersion: string(r.header.MemexVersion[:]), + } +} diff --git a/internal/memex/version.go b/internal/memex/version.go new file mode 100644 index 0000000..52ee29d --- /dev/null +++ b/internal/memex/version.go @@ -0,0 +1,18 @@ +package memex + +// Version information set by goreleaser +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +// Version returns the current version of memex +func Version() string { + return version +} + +// BuildInfo returns detailed build information +func BuildInfo() string { + return "Version: " + version + "\nCommit: " + commit + "\nBuild Date: " + date +} diff --git a/test/version_test.go b/test/version_test.go new file mode 100644 index 0000000..ca84d96 --- /dev/null +++ b/test/version_test.go @@ -0,0 +1,164 @@ +package test + +import ( + "os" + "strings" + "testing" + + "github.com/systemshift/memex/internal/memex/core" + "github.com/systemshift/memex/internal/memex/repository" +) + +func TestVersionCompatibility(t *testing.T) { + // Test version parsing + tests := []struct { + version string + wantMajor uint8 + wantMinor uint8 + wantError bool + errorMatch string + }{ + {"1.0", 1, 0, false, ""}, + {"2.1", 2, 1, false, ""}, + {"0.9", 0, 9, false, ""}, + {"1", 0, 0, true, "invalid version format"}, + {"1.a", 0, 0, true, "invalid minor version"}, + {"a.1", 0, 0, true, "invalid major version"}, + {"1.1.1", 0, 0, true, "invalid version format"}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + got, err := core.ParseVersion(tt.version) + if tt.wantError { + if err == nil { + t.Errorf("ParseVersion(%q) = %v, want error", tt.version, got) + } else if tt.errorMatch != "" { + checkError(t, err, tt.errorMatch) + } + } else { + if err != nil { + t.Errorf("ParseVersion(%q) error = %v", tt.version, err) + } + if got.Major != tt.wantMajor || got.Minor != tt.wantMinor { + t.Errorf("ParseVersion(%q) = {%d, %d}, want {%d, %d}", tt.version, got.Major, got.Minor, tt.wantMajor, tt.wantMinor) + } + } + }) + } + + // Test version compatibility + compatTests := []struct { + current core.RepositoryVersion + repo core.RepositoryVersion + expected bool + }{ + // Same versions are compatible + {core.RepositoryVersion{Major: 1, Minor: 0}, core.RepositoryVersion{Major: 1, Minor: 0}, true}, + // Current version can open older minor versions + {core.RepositoryVersion{Major: 1, Minor: 1}, core.RepositoryVersion{Major: 1, Minor: 0}, true}, + // Current version cannot open newer minor versions + {core.RepositoryVersion{Major: 1, Minor: 0}, core.RepositoryVersion{Major: 1, Minor: 1}, false}, + // Different major versions are incompatible + {core.RepositoryVersion{Major: 1, Minor: 0}, core.RepositoryVersion{Major: 2, Minor: 0}, false}, + {core.RepositoryVersion{Major: 2, Minor: 0}, core.RepositoryVersion{Major: 1, Minor: 0}, false}, + } + + for _, tt := range compatTests { + t.Run(tt.current.String()+"-"+tt.repo.String(), func(t *testing.T) { + got := tt.current.IsCompatible(tt.repo) + if got != tt.expected { + t.Errorf("IsCompatible(%v, %v) = %v, want %v", tt.current, tt.repo, got, tt.expected) + } + }) + } +} + +func TestRepositoryVersioning(t *testing.T) { + testFile := "test_version.mx" + defer cleanup(t, testFile) + + // Create a repository + repo, err := repository.Create(testFile) + if err != nil { + t.Fatalf("Failed to create repository: %v", err) + } + + // Check version info + info := repo.GetVersionInfo() + if info.FormatVersion != core.CurrentVersion.Major { + t.Errorf("Repository format version = %d, want %d", info.FormatVersion, core.CurrentVersion.Major) + } + if info.FormatMinor != core.CurrentVersion.Minor { + t.Errorf("Repository format minor = %d, want %d", info.FormatMinor, core.CurrentVersion.Minor) + } + + // Close and reopen repository + repo.Close() + repo, err = repository.Open(testFile) + if err != nil { + t.Fatalf("Failed to open repository: %v", err) + } + + // Check version info again + info = repo.GetVersionInfo() + if info.FormatVersion != core.CurrentVersion.Major { + t.Errorf("Reopened repository format version = %d, want %d", info.FormatVersion, core.CurrentVersion.Major) + } + if info.FormatMinor != core.CurrentVersion.Minor { + t.Errorf("Reopened repository format minor = %d, want %d", info.FormatMinor, core.CurrentVersion.Minor) + } + + // Verify memex version is stored + if info.MemexVersion == "" { + t.Error("Repository memex version is empty") + } +} + +func TestIncompatibleVersion(t *testing.T) { + testFile := "test_incompatible.mx" + defer cleanup(t, testFile) + + // Create a repository with future version + repo, err := repository.Create(testFile) + if err != nil { + t.Fatalf("Failed to create repository: %v", err) + } + + // Modify version to be incompatible + repo.GetVersionInfo() + repo.Close() + + // Try to open with incompatible version + savedCurrent := core.CurrentVersion + defer func() { core.CurrentVersion = savedCurrent }() + + core.CurrentVersion = core.RepositoryVersion{Major: 2, Minor: 0} + _, err = repository.Open(testFile) + if err == nil { + t.Error("Expected error opening repository with incompatible version") + } else { + checkError(t, err, "incompatible repository version") + } +} + +// cleanup removes test files +func cleanup(t *testing.T, files ...string) { + for _, f := range files { + if err := os.Remove(f); err != nil && !os.IsNotExist(err) { + t.Errorf("Failed to clean up %s: %v", f, err) + } + } +} + +// Helper function to check if error contains expected message +func checkError(t *testing.T, err error, want string) { + t.Helper() + if err == nil { + t.Errorf("Expected error containing %q, got nil", want) + return + } + if !strings.Contains(err.Error(), want) { + t.Errorf("Expected error containing %q, got %q", want, err.Error()) + } +}