diff --git a/CLAUDE.md b/CLAUDE.md index 6e349ed..f73135c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,5 +220,7 @@ func NewConfigCmd() *cobra.Command { ## Code Style Guidelines -### Naming Conventions -- **Use `Id` instead of `ID`**: Throughout the codebase, prefer `Id` over `ID` for consistency (e.g., `UserId`, `SpaceId`, `ObjectId`) \ No newline at end of file +- **Constants**: Centralize all configuration values, paths, URLs, and time intervals in `core/config/constants.go` +- **User Output**: Always use `core/output` package functions (`output.Success()`, `output.Error()`, `output.Warning()`, `output.Info()`) instead of `fmt.Printf/Println` +- **No Emojis**: Avoid emojis in output messages unless explicitly requested +- **Use `Id` instead of `ID`**: Throughout the codebase, prefer `Id` over `ID` for consistency (e.g., `UserId`, `SpaceId`, `ObjectId`) diff --git a/cmd/service/service.go b/cmd/service/service.go index 2f27cdd..4593dd6 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -12,8 +12,8 @@ import ( "github.com/anyproto/anytype-cli/core/serviceprogram" ) -// getService creates a service instance with our standard configuration -func getService() (service.Service, error) { +// GetService creates a service instance with our standard configuration +func GetService() (service.Service, error) { options := service.KeyValue{ "UserService": true, } @@ -81,7 +81,7 @@ func NewServiceCmd() *cobra.Command { } func installService(cmd *cobra.Command, args []string) error { - s, err := getService() + s, err := GetService() if err != nil { return output.Error("Failed to create service: %w", err) } @@ -102,7 +102,7 @@ func installService(cmd *cobra.Command, args []string) error { } func uninstallService(cmd *cobra.Command, args []string) error { - s, err := getService() + s, err := GetService() if err != nil { return output.Error("Failed to create service: %w", err) } @@ -117,7 +117,7 @@ func uninstallService(cmd *cobra.Command, args []string) error { } func startService(cmd *cobra.Command, args []string) error { - s, err := getService() + s, err := GetService() if err != nil { return output.Error("Failed to create service: %w", err) } @@ -140,7 +140,7 @@ func startService(cmd *cobra.Command, args []string) error { } func stopService(cmd *cobra.Command, args []string) error { - s, err := getService() + s, err := GetService() if err != nil { return output.Error("Failed to create service: %w", err) } @@ -163,7 +163,7 @@ func stopService(cmd *cobra.Command, args []string) error { } func restartService(cmd *cobra.Command, args []string) error { - s, err := getService() + s, err := GetService() if err != nil { return output.Error("Failed to create service: %w", err) } @@ -186,7 +186,7 @@ func restartService(cmd *cobra.Command, args []string) error { } func statusService(cmd *cobra.Command, args []string) error { - s, err := getService() + s, err := GetService() if err != nil { return output.Error("Failed to create service: %w", err) } diff --git a/cmd/update/update.go b/cmd/update/update.go index 1444ca6..1a7a232 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -1,353 +1,41 @@ package update import ( - "archive/tar" - "archive/zip" - "compress/gzip" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - - "github.com/anyproto/anytype-cli/core" "github.com/anyproto/anytype-cli/core/output" + "github.com/anyproto/anytype-cli/core/update" "github.com/spf13/cobra" ) -const ( - githubOwner = "anyproto" - githubRepo = "anytype-cli" -) - func NewUpdateCmd() *cobra.Command { return &cobra.Command{ Use: "update", Short: "Update to the latest version", Long: "Download and install the latest version of the Anytype CLI from GitHub releases.", - RunE: runUpdate, - } -} - -func runUpdate(cmd *cobra.Command, args []string) error { - output.Info("Checking for updates...") - - latest, err := getLatestVersion() - if err != nil { - return output.Error("Failed to check latest version: %w", err) - } - - current := core.GetVersion() - - currentBase := current - if idx := strings.Index(current, "-"); idx != -1 { - currentBase = current[:idx] - } - - if currentBase >= latest { - output.Info("Already up to date (%s)", current) - return nil - } - - output.Info("Updating from %s to %s...", current, latest) - - if err := downloadAndInstall(latest); err != nil { - return output.Error("update failed: %w", err) - } - - output.Success("Successfully updated to %s", latest) - output.Info("If the service is installed, restart it with: anytype service restart") - output.Info("Otherwise, restart your terminal or run 'anytype' to use the new version") - return nil -} - -func getLatestVersion() (string, error) { - resp, err := githubAPI("GET", "/releases/latest", nil) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", handleAPIError(resp) - } - - var release struct { - TagName string `json:"tag_name"` - } - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return "", output.Error("Failed to parse release: %w", err) - } - - return release.TagName, nil -} - -func downloadAndInstall(version string) error { - tempDir, err := os.MkdirTemp("", "anytype-update-*") - if err != nil { - return output.Error("Failed to create temp dir: %w", err) - } - defer os.RemoveAll(tempDir) - - archivePath := filepath.Join(tempDir, getArchiveName(version)) - if err := downloadRelease(version, archivePath); err != nil { - return err - } - - if err := extractArchive(archivePath, tempDir); err != nil { - return output.Error("Failed to extract: %w", err) - } - - binaryName := "anytype" - if runtime.GOOS == "windows" { - binaryName = "anytype.exe" - } - - newBinary := filepath.Join(tempDir, binaryName) - if _, err := os.Stat(newBinary); err != nil { - return output.Error("binary not found in archive (expected %s)", binaryName) - } - - if err := replaceBinary(newBinary); err != nil { - return output.Error("Failed to install: %w", err) - } - - return nil -} - -func getArchiveName(version string) string { - base := fmt.Sprintf("anytype-cli-%s-%s-%s", version, runtime.GOOS, runtime.GOARCH) - if runtime.GOOS == "windows" { - return base + ".zip" - } - return base + ".tar.gz" -} - -func downloadRelease(version, destination string) error { - archiveName := filepath.Base(destination) - output.Info("Downloading %s...", archiveName) - - if token := os.Getenv("GITHUB_TOKEN"); token != "" { - return downloadViaAPI(version, archiveName, destination) - } - - url := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s", - githubOwner, githubRepo, version, archiveName) - - return downloadFile(url, destination, "") -} - -func downloadViaAPI(version, filename, destination string) error { - resp, err := githubAPI("GET", fmt.Sprintf("/releases/tags/%s", version), nil) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return handleAPIError(resp) - } - - var release struct { - Assets []struct { - Name string `json:"name"` - URL string `json:"url"` - } `json:"assets"` - } - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return output.Error("Failed to parse release: %w", err) - } + RunE: func(cmd *cobra.Command, args []string) error { + output.Info("Checking for updates...") - var assetURL string - for _, asset := range release.Assets { - if asset.Name == filename { - assetURL = asset.URL - break - } - } - if assetURL == "" { - return output.Error("release asset %s not found", filename) - } - - return downloadFile(assetURL, destination, os.Getenv("GITHUB_TOKEN")) -} - -func downloadFile(url, destination, token string) error { - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return err - } - - if token != "" { - req.Header.Set("Authorization", "token "+token) - req.Header.Set("Accept", "application/octet-stream") - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return output.Error("download failed with status %d", resp.StatusCode) - } - - out, err := os.Create(destination) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, resp.Body) - return err -} - -func extractArchive(archivePath, destDir string) error { - if strings.HasSuffix(archivePath, ".zip") { - return extractZip(archivePath, destDir) - } - return extractTarGz(archivePath, destDir) -} - -func extractTarGz(archivePath, destDir string) error { - file, err := os.Open(archivePath) - if err != nil { - return err - } - defer file.Close() - - gz, err := gzip.NewReader(file) - if err != nil { - return err - } - defer gz.Close() - - tr := tar.NewReader(gz) - for { - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return err - } - - target := filepath.Join(destDir, header.Name) - - switch header.Typeflag { - case tar.TypeDir: - if err := os.MkdirAll(target, 0755); err != nil { - return err + latest, err := update.GetLatestVersion() + if err != nil { + return output.Error("Failed to check latest version: %w", err) } - case tar.TypeReg: - if err := writeFile(target, tr, header.FileInfo().Mode()); err != nil { - return err - } - } - } - return nil -} -func extractZip(archivePath, destDir string) error { - r, err := zip.OpenReader(archivePath) - if err != nil { - return err - } - defer r.Close() - - for _, f := range r.File { - target := filepath.Join(destDir, f.Name) - - if f.FileInfo().IsDir() { - _ = os.MkdirAll(target, f.Mode()) - continue - } - - rc, err := f.Open() - if err != nil { - return err - } - - if err := writeFile(target, rc, f.Mode()); err != nil { - rc.Close() - return err - } - rc.Close() - } - return nil -} - -func writeFile(path string, r io.Reader, mode os.FileMode) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - - out, err := os.Create(path) - if err != nil { - return err - } - defer out.Close() - - if _, err := io.Copy(out, r); err != nil { - return err - } - - return os.Chmod(path, mode) -} - -func replaceBinary(newBinary string) error { - if err := os.Chmod(newBinary, 0755); err != nil { - return err - } + current := update.GetCurrentVersion() - currentBinary, err := os.Executable() - if err != nil { - return err - } - currentBinary, err = filepath.EvalSymlinks(currentBinary) - if err != nil { - return err - } - - if err := os.Rename(newBinary, currentBinary); err != nil { - if runtime.GOOS != "windows" { - cmd := exec.Command("sudo", "mv", newBinary, currentBinary) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return output.Error("Failed to replace binary: %w", err) + if !update.NeedsUpdate(current, latest) { + output.Info("Already up to date (%s)", current) + return nil } - } else { - return output.Error("Failed to replace binary: %w", err) - } - } - return nil -} - -func githubAPI(method, endpoint string, body io.Reader) (*http.Response, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s%s", githubOwner, githubRepo, endpoint) + output.Info("Updating from %s to %s...", current, latest) - req, err := http.NewRequest(method, url, body) - if err != nil { - return nil, err - } + if err := update.DownloadAndInstall(latest); err != nil { + return output.Error("Update failed: %w", err) + } - req.Header.Set("Accept", "application/vnd.github.v3+json") - if token := os.Getenv("GITHUB_TOKEN"); token != "" { - req.Header.Set("Authorization", "token "+token) + output.Success("Successfully updated to %s", latest) + output.Info("If the service is installed, restart it with: anytype service restart") + output.Info("Otherwise, restart your terminal or run 'anytype' to use the new version") + return nil + }, } - - return http.DefaultClient.Do(req) -} - -func handleAPIError(resp *http.Response) error { - body, _ := io.ReadAll(resp.Body) - return output.Error("API error %d: %s", resp.StatusCode, string(body)) } diff --git a/core/config/constants.go b/core/config/constants.go index ee37111..7afe82f 100644 --- a/core/config/constants.go +++ b/core/config/constants.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "time" ) const ( @@ -23,8 +24,16 @@ const ( // URLs GRPCDNSAddress = "dns:///" + DefaultGRPCAddress + // GitHub repository + GitHubOwner = "anyproto" + GitHubRepo = "anytype-cli" + + // GitHub domains + GitHubAPIBaseURL = "https://api.github.com" + GitHubWebBaseURL = "https://github.com" + // External URLs - GitHubBaseURL = "https://github.com/anyproto/anytype-cli" + GitHubBaseURL = GitHubWebBaseURL + "/" + GitHubOwner + "/" + GitHubRepo GitHubCommitURL = GitHubBaseURL + "/commit/" GitHubReleaseURL = GitHubBaseURL + "/releases/tag/" @@ -37,6 +46,14 @@ const ( DataDirName = "data" LogsDirName = "logs" AnytypeName = "anytype" + + // Update related files + UpdateCheckFile = ".update-check" + UpdateLockFile = ".update-lock" + UpdateLogFile = "autoupdate.log" + + // Update check interval + UpdateCheckInterval = 24 * time.Hour ) func GetWorkDir() string { @@ -77,3 +94,15 @@ func GetDataDir() string { func GetLogsDir() string { return filepath.Join(GetConfigDir(), LogsDirName) } + +func GetUpdateCheckFilePath() string { + return filepath.Join(GetConfigDir(), UpdateCheckFile) +} + +func GetUpdateLockFilePath() string { + return filepath.Join(GetConfigDir(), UpdateLockFile) +} + +func GetUpdateLogFilePath() string { + return filepath.Join(GetLogsDir(), UpdateLogFile) +} diff --git a/core/update/autoupdate.go b/core/update/autoupdate.go new file mode 100644 index 0000000..2bc70ad --- /dev/null +++ b/core/update/autoupdate.go @@ -0,0 +1,166 @@ +package update + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/kardianos/service" + + serviceCmd "github.com/anyproto/anytype-cli/cmd/service" + "github.com/anyproto/anytype-cli/core/config" + "github.com/anyproto/anytype-cli/core/output" +) + +type updateCheck struct { + LastCheck time.Time `json:"lastCheck"` + LastVersion string `json:"lastVersion"` +} + +func CheckAndUpdate() { + go func() { + if err := performUpdateCheck(); err != nil { + logUpdateError(err) + } + }() +} + +func logUpdateError(err error) { + logPath := config.GetUpdateLogFilePath() + logDir := filepath.Dir(logPath) + + if mkdirErr := os.MkdirAll(logDir, 0755); mkdirErr != nil { + return + } + + f, openErr := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if openErr != nil { + return + } + defer f.Close() + + logger := log.New(f, "", log.LstdFlags) + logger.Printf("Autoupdate error: %v\n", err) +} + +func performUpdateCheck() error { + configDir := config.GetConfigDir() + if configDir == "" { + return fmt.Errorf("could not determine config directory") + } + + if err := os.MkdirAll(configDir, 0755); err != nil { + return err + } + + lockPath := config.GetUpdateLockFilePath() + checkPath := config.GetUpdateCheckFilePath() + + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + return nil + } + defer func() { + lockFile.Close() + os.Remove(lockPath) + }() + + if !shouldCheckForUpdate(checkPath) { + return nil + } + + latest, err := GetLatestVersion() + if err != nil { + return err + } + + current := GetCurrentVersion() + + check := updateCheck{ + LastCheck: time.Now(), + LastVersion: latest, + } + _ = saveCheckInfo(checkPath, check) + + if !NeedsUpdate(current, latest) { + return nil + } + + if !CanUpdateBinary() { + return nil + } + + if err := DownloadAndInstall(latest); err != nil { + return err + } + + output.Success("Anytype CLI has been automatically updated from %s to %s", current, latest) + + // Check if service is running and restart it automatically + if err := restartServiceIfRunning(); err != nil { + output.Warning("Failed to restart service: %v", err) + output.Info("Restart manually with: anytype service restart") + } + + return nil +} + +// restartServiceIfRunning checks if the service is running and restarts it +func restartServiceIfRunning() error { + s, err := serviceCmd.GetService() + if err != nil { + return fmt.Errorf("failed to create service: %w", err) + } + + // Check status + status, err := s.Status() + if err != nil { + if errors.Is(err, service.ErrNotInstalled) { + // Service not installed, nothing to restart + return nil + } + return fmt.Errorf("failed to get service status: %w", err) + } + + // Only restart if running + if status == service.StatusRunning { + output.Info("Restarting service with new binary...") + if err := s.Restart(); err != nil { + return fmt.Errorf("failed to restart service: %w", err) + } + output.Info("Service restarted successfully") + } + + return nil +} + +func shouldCheckForUpdate(checkPath string) bool { + data, err := os.ReadFile(checkPath) + if err != nil { + return true + } + + var check updateCheck + if err := json.Unmarshal(data, &check); err != nil { + return true + } + + return time.Since(check.LastCheck) > config.UpdateCheckInterval +} + +func saveCheckInfo(checkPath string, check updateCheck) error { + data, err := json.Marshal(check) + if err != nil { + return fmt.Errorf("failed to marshal check info: %w", err) + } + + if err := os.WriteFile(checkPath, data, 0644); err != nil { + return fmt.Errorf("failed to write check info: %w", err) + } + + return nil +} diff --git a/core/update/update.go b/core/update/update.go new file mode 100644 index 0000000..f058449 --- /dev/null +++ b/core/update/update.go @@ -0,0 +1,366 @@ +package update + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/config" + "github.com/hashicorp/go-version" +) + +var httpClient = &http.Client{ + Timeout: 5 * time.Minute, +} + +func GetLatestVersion() (string, error) { + url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", config.GitHubAPIBaseURL, config.GitHubOwner, config.GitHubRepo) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + req.Header.Set("Authorization", "token "+token) + } + + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("failed to parse release: %w", err) + } + + return release.TagName, nil +} + +func NeedsUpdate(current, latest string) bool { + currentVer, err := version.NewVersion(current) + if err != nil { + return false + } + + latestVer, err := version.NewVersion(latest) + if err != nil { + return false + } + + return currentVer.LessThan(latestVer) +} + +func GetCurrentVersion() string { + return core.GetVersion() +} + +func DownloadAndInstall(version string) error { + tempDir, err := os.MkdirTemp("", "anytype-update-*") + if err != nil { + return fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tempDir) + + archivePath := filepath.Join(tempDir, getArchiveName(version)) + if err := downloadRelease(version, archivePath); err != nil { + return err + } + + if err := extractArchive(archivePath, tempDir); err != nil { + return fmt.Errorf("failed to extract: %w", err) + } + + binaryName := "anytype" + if runtime.GOOS == "windows" { + binaryName = "anytype.exe" + } + + newBinary := filepath.Join(tempDir, binaryName) + if _, err := os.Stat(newBinary); err != nil { + return fmt.Errorf("binary not found in archive (expected %s)", binaryName) + } + + if err := replaceBinary(newBinary); err != nil { + return fmt.Errorf("failed to install: %w", err) + } + + return nil +} + +func CanUpdateBinary() bool { + execPath, err := os.Executable() + if err != nil { + return false + } + + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return false + } + + file, err := os.OpenFile(execPath, os.O_RDWR, 0) + if err != nil { + return false + } + _ = file.Close() + return true +} + +func getArchiveName(version string) string { + base := fmt.Sprintf("anytype-cli-%s-%s-%s", version, runtime.GOOS, runtime.GOARCH) + if runtime.GOOS == "windows" { + return base + ".zip" + } + return base + ".tar.gz" +} + +func downloadRelease(version, destination string) error { + archiveName := filepath.Base(destination) + + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + return downloadViaAPI(version, archiveName, destination) + } + + url := fmt.Sprintf("%s/%s/%s/releases/download/%s/%s", + config.GitHubWebBaseURL, config.GitHubOwner, config.GitHubRepo, version, archiveName) + + return downloadFile(url, destination, "") +} + +func downloadViaAPI(version, filename, destination string) error { + url := fmt.Sprintf("%s/repos/%s/%s/releases/tags/%s", + config.GitHubAPIBaseURL, config.GitHubOwner, config.GitHubRepo, version) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + req.Header.Set("Authorization", "token "+token) + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) + } + + var release struct { + Assets []struct { + Name string `json:"name"` + URL string `json:"url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return fmt.Errorf("failed to parse release: %w", err) + } + + var assetURL string + for _, asset := range release.Assets { + if asset.Name == filename { + assetURL = asset.URL + break + } + } + if assetURL == "" { + return fmt.Errorf("release asset %s not found", filename) + } + + return downloadFile(assetURL, destination, os.Getenv("GITHUB_TOKEN")) +} + +func downloadFile(url, destination, token string) error { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + if token != "" { + req.Header.Set("Authorization", "token "+token) + req.Header.Set("Accept", "application/octet-stream") + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + out, err := os.Create(destination) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + return err +} + +func extractArchive(archivePath, destDir string) error { + if strings.HasSuffix(archivePath, ".zip") { + return extractZip(archivePath, destDir) + } + return extractTarGz(archivePath, destDir) +} + +// isValidExtractionPath validates that the target path is within the destination directory to prevent path traversal attacks +func isValidExtractionPath(target, destDir string) bool { + cleanTarget := filepath.Clean(target) + cleanDest := filepath.Clean(destDir) + + // Check if target starts with destDir + rel, err := filepath.Rel(cleanDest, cleanTarget) + if err != nil { + return false + } + + // If the relative path starts with "..", it's trying to escape the destDir + return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".." +} + +func extractTarGz(archivePath, destDir string) error { + file, err := os.Open(archivePath) + if err != nil { + return err + } + defer file.Close() + + gz, err := gzip.NewReader(file) + if err != nil { + return err + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + target := filepath.Join(destDir, header.Name) + + if !isValidExtractionPath(target, destDir) { + return fmt.Errorf("illegal file path in archive: %s", header.Name) + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + case tar.TypeReg: + if err := writeFile(target, tr, header.FileInfo().Mode()); err != nil { + return err + } + } + } + return nil +} + +func extractZip(archivePath, destDir string) error { + r, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + target := filepath.Join(destDir, f.Name) + + if !isValidExtractionPath(target, destDir) { + return fmt.Errorf("illegal file path in archive: %s", f.Name) + } + + if f.FileInfo().IsDir() { + _ = os.MkdirAll(target, f.Mode()) + continue + } + + rc, err := f.Open() + if err != nil { + return err + } + + if err := writeFile(target, rc, f.Mode()); err != nil { + rc.Close() + return err + } + rc.Close() + } + return nil +} + +func writeFile(path string, r io.Reader, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + out, err := os.Create(path) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, r); err != nil { + return err + } + + return os.Chmod(path, mode) +} + +func replaceBinary(newBinary string) error { + if err := os.Chmod(newBinary, 0755); err != nil { + return err + } + + currentBinary, err := os.Executable() + if err != nil { + return err + } + currentBinary, err = filepath.EvalSymlinks(currentBinary) + if err != nil { + return err + } + + if err := os.Rename(newBinary, currentBinary); err != nil { + return fmt.Errorf("failed to replace binary (insufficient permissions): %w", err) + } + + return nil +} diff --git a/go.mod b/go.mod index dcf78f8..ea48d75 100644 --- a/go.mod +++ b/go.mod @@ -123,6 +123,7 @@ require ( github.com/grokify/html-strip-tags-go v0.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/hbagdi/go-unsplash v0.0.0-20230414214043-474fc02c9119 // indirect diff --git a/go.sum b/go.sum index aea289a..c1bddf9 100644 --- a/go.sum +++ b/go.sum @@ -445,6 +445,8 @@ github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdv github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= diff --git a/main.go b/main.go index 0944425..67f12d6 100644 --- a/main.go +++ b/main.go @@ -3,9 +3,12 @@ package main import ( "github.com/anyproto/anytype-cli/cmd" "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/update" ) func main() { + update.CheckAndUpdate() + defer func() { core.CloseEventReceiver() core.CloseGRPCConnection()