refactor: move updater to dedicated core/go-update repo

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-21 21:38:27 +00:00
parent 338a0a4c5e
commit b95d465499
44 changed files with 0 additions and 11948 deletions

View file

@ -1,18 +0,0 @@
# Go
updater
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
*.prof
# Node
node_modules/
dist/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View file

@ -1,287 +0,0 @@
EUROPEAN UNION PUBLIC LICENCE v. 1.2
EUPL © the European Union 2007, 2016
This European Union Public Licence (the EUPL) applies to the Work (as defined
below) which is provided under the terms of this Licence. Any use of the Work,
other than as authorised under this Licence is prohibited (to the extent such
use is covered by a right of the copyright holder of the Work).
The Work is provided under the terms of this Licence when the Licensor (as
defined below) has placed the following notice immediately following the
copyright notice for the Work:
Licensed under the EUPL
or has expressed by any other means his willingness to license under the EUPL.
1. Definitions
In this Licence, the following terms have the following meaning:
- The Licence: this Licence.
- The Original Work: the work or software distributed or communicated by the
Licensor under this Licence, available as Source Code and also as Executable
Code as the case may be.
- Derivative Works: the works or software that could be created by the
Licensee, based upon the Original Work or modifications thereof. This Licence
does not define the extent of modification or dependence on the Original Work
required in order to classify a work as a Derivative Work; this extent is
determined by copyright law applicable in the country mentioned in Article 15.
- The Work: the Original Work or its Derivative Works.
- The Source Code: the human-readable form of the Work which is the most
convenient for people to study and modify.
- The Executable Code: any code which has generally been compiled and which is
meant to be interpreted by a computer as a program.
- The Licensor: the natural or legal person that distributes or communicates
the Work under the Licence.
- Contributor(s): any natural or legal person who modifies the Work under the
Licence, or otherwise contributes to the creation of a Derivative Work.
- The Licensee or You: any natural or legal person who makes any usage of
the Work under the terms of the Licence.
- Distribution or Communication: any act of selling, giving, lending,
renting, distributing, communicating, transmitting, or otherwise making
available, online or offline, copies of the Work or providing access to its
essential functionalities at the disposal of any other natural or legal
person.
2. Scope of the rights granted by the Licence
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
sublicensable licence to do the following, for the duration of copyright vested
in the Original Work:
- use the Work in any circumstance and for all usage,
- reproduce the Work,
- modify the Work, and make Derivative Works based upon the Work,
- communicate to the public, including the right to make available or display
the Work or copies thereof to the public and perform publicly, as the case may
be, the Work,
- distribute the Work or copies thereof,
- lend and rent the Work or copies thereof,
- sublicense rights in the Work or copies thereof.
Those rights can be exercised on any media, supports and formats, whether now
known or later invented, as far as the applicable law permits so.
In the countries where moral rights apply, the Licensor waives his right to
exercise his moral right to the extent allowed by law in order to make effective
the licence of the economic rights here above listed.
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
any patents held by the Licensor, to the extent necessary to make use of the
rights granted on the Work under this Licence.
3. Communication of the Source Code
The Licensor may provide the Work either in its Source Code form, or as
Executable Code. If the Work is provided as Executable Code, the Licensor
provides in addition a machine-readable copy of the Source Code of the Work
along with each copy of the Work that the Licensor distributes or indicates, in
a notice following the copyright notice attached to the Work, a repository where
the Source Code is easily and freely accessible for as long as the Licensor
continues to distribute or communicate the Work.
4. Limitations on copyright
Nothing in this Licence is intended to deprive the Licensee of the benefits from
any exception or limitation to the exclusive rights of the rights owners in the
Work, of the exhaustion of those rights or of other applicable limitations
thereto.
5. Obligations of the Licensee
The grant of the rights mentioned above is subject to some restrictions and
obligations imposed on the Licensee. Those obligations are the following:
Attribution right: The Licensee shall keep intact all copyright, patent or
trademarks notices and all notices that refer to the Licence and to the
disclaimer of warranties. The Licensee must include a copy of such notices and a
copy of the Licence with every copy of the Work he/she distributes or
communicates. The Licensee must cause any Derivative Work to carry prominent
notices stating that the Work has been modified and the date of modification.
Copyleft clause: If the Licensee distributes or communicates copies of the
Original Works or Derivative Works, this Distribution or Communication will be
done under the terms of this Licence or of a later version of this Licence
unless the Original Work is expressly distributed only under this version of the
Licence — for example by communicating EUPL v. 1.2 only. The Licensee
(becoming Licensor) cannot offer or impose any additional terms or conditions on
the Work or Derivative Work that alter or restrict the terms of the Licence.
Compatibility clause: If the Licensee Distributes or Communicates Derivative
Works or copies thereof based upon both the Work and another work licensed under
a Compatible Licence, this Distribution or Communication can be done under the
terms of this Compatible Licence. For the sake of this clause, Compatible
Licence refers to the licences listed in the appendix attached to this Licence.
Should the Licensee's obligations under the Compatible Licence conflict with
his/her obligations under this Licence, the obligations of the Compatible
Licence shall prevail.
Provision of Source Code: When distributing or communicating copies of the Work,
the Licensee will provide a machine-readable copy of the Source Code or indicate
a repository where this Source will be easily and freely available for as long
as the Licensee continues to distribute or communicate the Work.
Legal Protection: This Licence does not grant permission to use the trade names,
trademarks, service marks, or names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the copyright notice.
6. Chain of Authorship
The original Licensor warrants that the copyright in the Original Work granted
hereunder is owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each Contributor warrants that the copyright in the modifications he/she brings
to the Work are owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each time You accept the Licence, the original Licensor and subsequent
Contributors grant You a licence to their contributions to the Work, under the
terms of this Licence.
7. Disclaimer of Warranty
The Work is a work in progress, which is continuously improved by numerous
Contributors. It is not a finished work and may therefore contain defects or
bugs inherent to this type of development.
For the above reason, the Work is provided under the Licence on an as is basis
and without warranties of any kind concerning the Work, including without
limitation merchantability, fitness for a particular purpose, absence of defects
or errors, accuracy, non-infringement of intellectual property rights other than
copyright as stated in Article 6 of this Licence.
This disclaimer of warranty is an essential part of the Licence and a condition
for the grant of any rights to the Work.
8. Disclaimer of Liability
Except in the cases of wilful misconduct or damages directly caused to natural
persons, the Licensor will in no event be liable for any direct or indirect,
material or moral, damages of any kind, arising out of the Licence or of the use
of the Work, including without limitation, damages for loss of goodwill, work
stoppage, computer failure or malfunction, loss of data or any commercial
damage, even if the Licensor has been advised of the possibility of such damage.
However, the Licensor will be liable under statutory product liability laws as
far such laws apply to the Work.
9. Additional agreements
While distributing the Work, You may choose to conclude an additional agreement,
defining obligations or services consistent with this Licence. However, if
accepting obligations, You may act only on your own behalf and on your sole
responsibility, not on behalf of the original Licensor or any other Contributor,
and only if You agree to indemnify, defend, and hold each Contributor harmless
for any liability incurred by, or claims asserted against such Contributor by
the fact You have accepted any warranty or additional liability.
10. Acceptance of the Licence
The provisions of this Licence can be accepted by clicking on an icon I agree
placed under the bottom of a window displaying the text of this Licence or by
affirming consent in any other similar way, in accordance with the rules of
applicable law. Clicking on that icon indicates your clear and irrevocable
acceptance of this Licence and all of its terms and conditions.
Similarly, you irrevocably accept this Licence and all of its terms and
conditions by exercising any rights granted to You by Article 2 of this Licence,
such as the use of the Work, the creation by You of a Derivative Work or the
Distribution or Communication by You of the Work or copies thereof.
11. Information to the public
In case of any Distribution or Communication of the Work by means of electronic
communication by You (for example, by offering to download the Work from a
remote location) the distribution channel or media (for example, a website) must
at least provide to the public the information requested by the applicable law
regarding the Licensor, the Licence and the way it may be accessible, concluded,
stored and reproduced by the Licensee.
12. Termination of the Licence
The Licence and the rights granted hereunder will terminate automatically upon
any breach by the Licensee of the terms of the Licence.
Such a termination will not terminate the licences of any person who has
received the Work from the Licensee under the Licence, provided such persons
remain in full compliance with the Licence.
13. Miscellaneous
Without prejudice of Article 9 above, the Licence represents the complete
agreement between the Parties as to the Work.
If any provision of the Licence is invalid or unenforceable under applicable
law, this will not affect the validity or enforceability of the Licence as a
whole. Such provision will be construed or reformed so as necessary to make it
valid and enforceable.
The European Commission may publish other linguistic versions or new versions of
this Licence or updated versions of the Appendix, so far this is required and
reasonable, without reducing the scope of the rights granted by the Licence. New
versions of the Licence will be published with a unique version number.
All linguistic versions of this Licence, approved by the European Commission,
have identical value. Parties can take advantage of the linguistic version of
their choice.
14. Jurisdiction
Without prejudice to specific agreement between parties,
- any litigation resulting from the interpretation of this License, arising
between the European Union institutions, bodies, offices or agencies, as a
Licensor, and any Licensee, will be subject to the jurisdiction of the Court
of Justice of the European Union, as laid down in article 272 of the Treaty on
the Functioning of the European Union,
- any litigation arising between other parties and resulting from the
interpretation of this License, will be subject to the exclusive jurisdiction
of the competent court where the Licensor resides or conducts its primary
business.
15. Applicable Law
Without prejudice to specific agreement between parties,
- this Licence shall be governed by the law of the European Union Member State
where the Licensor has his seat, resides or has his registered office,
- this licence shall be governed by Belgian law if the Licensor has no seat,
residence or registered office inside a European Union Member State.
Appendix
Compatible Licences according to Article 5 EUPL are:
- GNU General Public License (GPL) v. 2, v. 3
- GNU Affero General Public License (AGPL) v. 3
- Open Software License (OSL) v. 2.1, v. 3.0
- Eclipse Public License (EPL) v. 1.0
- CeCILL v. 2.0, v. 2.1
- Mozilla Public Licence (MPL) v. 2
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
works other than software
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
Reciprocity (LiLiQ-R+).
The European Commission may update this Appendix to later versions of the above
licences without producing a new version of the EUPL, as long as they provide
the rights granted in Article 2 of this Licence and protect the covered Source
Code from exclusive appropriation.
All other changes or additions to this Appendix require the production of a new
EUPL version.

View file

@ -1,40 +0,0 @@
.PHONY: build dev release-local test coverage
BINARY_NAME=updater
CMD_PATH=./cmd/updater
# Default LDFLAGS to empty
LDFLAGS = ""
# If VERSION is set, override LDFLAGS
ifdef VERSION
LDFLAGS = -ldflags "-X 'github.com/snider/updater.Version=$(VERSION)'"
endif
.PHONY: generate
generate:
@echo "Generating code..."
@go generate ./...
build: generate
@echo "Building $(BINARY_NAME)..."
@cd $(CMD_PATH) && go build $(LDFLAGS)
dev: build
@echo "Running $(BINARY_NAME)..."
@$(CMD_PATH)/$(BINARY_NAME) --check-update
release-local:
@echo "Running local release with GoReleaser..."
@~/go/bin/goreleaser release --snapshot --clean
test:
@echo "Running tests..."
@go test ./...
coverage:
@echo "Generating code coverage report..."
@go test -coverprofile=coverage.out ./...
@echo "Coverage report generated: coverage.out"
@echo "To view in browser: go tool cover -html=coverage.out"
@echo "To upload to Codecov, ensure you have the Codecov CLI installed (e.g., 'go install github.com/codecov/codecov-cli@latest') and run: codecov -f coverage.out"

View file

@ -1,117 +0,0 @@
# Core Element Template
This repository is a template for developers to create custom HTML elements for the core web3 framework. It includes a Go backend, an Angular custom element, and a full release cycle configuration.
## Getting Started
1. **Clone the repository:**
```bash
git clone https://github.com/your-username/core-element-template.git
```
2. **Install the dependencies:**
```bash
cd core-element-template
go mod tidy
cd ui
npm install
```
3. **Run the development server:**
```bash
go run ./cmd/demo-cli serve
```
This will start the Go backend and serve the Angular custom element.
## Building the Custom Element
To build the Angular custom element, run the following command:
```bash
cd ui
npm run build
```
This will create a single JavaScript file in the `dist` directory that you can use in any HTML page.
## Usage
To use the updater library in your Go project, you can use the `UpdateService`.
### GitHub-based Updates
```go
package main
import (
"fmt"
"log"
"github.com/snider/updater"
)
func main() {
config := updater.UpdateServiceConfig{
RepoURL: "https://github.com/owner/repo",
Channel: "stable",
CheckOnStartup: updater.CheckAndUpdateOnStartup,
}
updateService, err := updater.NewUpdateService(config)
if err != nil {
log.Fatalf("Failed to create update service: %v", err)
}
if err := updateService.Start(); err != nil {
fmt.Printf("Update check failed: %v\n", err)
}
}
```
### Generic HTTP Updates
For updates from a generic HTTP server, the server should provide a `latest.json` file at the root of the `RepoURL`. The JSON file should have the following structure:
```json
{
"version": "1.2.3",
"url": "https://your-server.com/path/to/release-asset"
}
```
You can then configure the `UpdateService` as follows:
```go
package main
import (
"fmt"
"log"
"github.com/snider/updater"
)
func main() {
config := updater.UpdateServiceConfig{
RepoURL: "https://your-server.com",
CheckOnStartup: updater.CheckAndUpdateOnStartup,
}
updateService, err := updater.NewUpdateService(config)
if err != nil {
log.Fatalf("Failed to create update service: %v", err)
}
if err := updateService.Start(); err != nil {
fmt.Printf("Update check failed: %v\n", err)
}
}
```
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
This project is licensed under the EUPL-1.2 License - see the [LICENSE](LICENSE) file for details.

View file

@ -1,35 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
)
func main() {
// Read package.json
data, err := os.ReadFile("package.json")
if err != nil {
fmt.Println("Error reading package.json, skipping version file generation.")
os.Exit(0)
}
// Parse package.json
var pkg struct {
Version string `json:"version"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
fmt.Println("Error parsing package.json, skipping version file generation.")
os.Exit(0)
}
// Create the version file
content := fmt.Sprintf("package updater\n\n// Generated by go:generate. DO NOT EDIT.\n\nconst PkgVersion = %q\n", pkg.Version)
err = os.WriteFile("version.go", []byte(content), 0644)
if err != nil {
fmt.Printf("Error writing version file: %v\n", err)
os.Exit(1)
}
fmt.Println("Generated version.go with version:", pkg.Version)
}

View file

@ -1,216 +0,0 @@
package updater
import (
"context"
"fmt"
"runtime"
"forge.lthn.ai/core/go/pkg/cli"
"github.com/spf13/cobra"
)
// Repository configuration for updates
const (
repoOwner = "host-uk"
repoName = "core"
)
// Command flags
var (
updateChannel string
updateForce bool
updateCheck bool
updateWatchPID int
)
func init() {
cli.RegisterCommands(AddUpdateCommands)
}
// AddUpdateCommands registers the update command and subcommands.
func AddUpdateCommands(root *cobra.Command) {
updateCmd := &cobra.Command{
Use: "update",
Short: "Update core CLI to the latest version",
Long: `Update the core CLI to the latest version from GitHub releases.
By default, checks the 'stable' channel for tagged releases (v*.*.*)
Use --channel=dev for the latest development build.
Examples:
core update # Update to latest stable release
core update --check # Check for updates without applying
core update --channel=dev # Update to latest dev build
core update --force # Force update even if already on latest`,
RunE: runUpdate,
}
updateCmd.PersistentFlags().StringVar(&updateChannel, "channel", "stable", "Release channel: stable, beta, alpha, or dev")
updateCmd.PersistentFlags().BoolVar(&updateForce, "force", false, "Force update even if already on latest version")
updateCmd.Flags().BoolVar(&updateCheck, "check", false, "Only check for updates, don't apply")
updateCmd.Flags().IntVar(&updateWatchPID, "watch-pid", 0, "Internal: watch for parent PID to die then restart")
_ = updateCmd.Flags().MarkHidden("watch-pid")
updateCmd.AddCommand(&cobra.Command{
Use: "check",
Short: "Check for available updates",
RunE: func(cmd *cobra.Command, args []string) error {
updateCheck = true
return runUpdate(cmd, args)
},
})
root.AddCommand(updateCmd)
}
func runUpdate(cmd *cobra.Command, args []string) error {
// If we're in watch mode, wait for parent to die then restart
if updateWatchPID > 0 {
return watchAndRestart(updateWatchPID)
}
currentVersion := cli.AppVersion
cli.Print("%s %s\n", cli.DimStyle.Render("Current version:"), cli.ValueStyle.Render(currentVersion))
cli.Print("%s %s/%s\n", cli.DimStyle.Render("Platform:"), runtime.GOOS, runtime.GOARCH)
cli.Print("%s %s\n\n", cli.DimStyle.Render("Channel:"), updateChannel)
// Handle dev channel specially - it's a prerelease tag, not a semver channel
if updateChannel == "dev" {
return handleDevUpdate(currentVersion)
}
// Check for newer version
release, updateAvailable, err := CheckForNewerVersion(repoOwner, repoName, updateChannel, true)
if err != nil {
return cli.Wrap(err, "failed to check for updates")
}
if release == nil {
cli.Print("%s No releases found in %s channel\n", cli.WarningStyle.Render("!"), updateChannel)
return nil
}
if !updateAvailable && !updateForce {
cli.Print("%s Already on latest version (%s)\n",
cli.SuccessStyle.Render(cli.Glyph(":check:")),
release.TagName)
return nil
}
cli.Print("%s %s\n", cli.DimStyle.Render("Latest version:"), cli.SuccessStyle.Render(release.TagName))
if updateCheck {
if updateAvailable {
cli.Print("\n%s Update available: %s → %s\n",
cli.WarningStyle.Render("!"),
currentVersion,
release.TagName)
cli.Print("Run %s to update\n", cli.ValueStyle.Render("core update"))
}
return nil
}
// Spawn watcher before applying update
if err := spawnWatcher(); err != nil {
// If watcher fails, continue anyway - update will still work
cli.Print("%s Could not spawn restart watcher: %v\n", cli.DimStyle.Render("!"), err)
}
// Apply update
cli.Print("\n%s Downloading update...\n", cli.DimStyle.Render("→"))
downloadURL, err := GetDownloadURL(release, "")
if err != nil {
return cli.Wrap(err, "failed to get download URL")
}
if err := DoUpdate(downloadURL); err != nil {
return cli.Wrap(err, "failed to apply update")
}
cli.Print("%s Updated to %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), release.TagName)
cli.Print("%s Restarting...\n", cli.DimStyle.Render("→"))
return nil
}
// handleDevUpdate handles updates from the dev release (rolling prerelease)
func handleDevUpdate(currentVersion string) error {
client := NewGithubClient()
// Fetch the dev release directly by tag
release, err := client.GetLatestRelease(context.TODO(), repoOwner, repoName, "beta")
if err != nil {
// Try fetching the "dev" tag directly
return handleDevTagUpdate(currentVersion)
}
if release == nil {
return handleDevTagUpdate(currentVersion)
}
cli.Print("%s %s\n", cli.DimStyle.Render("Latest dev:"), cli.ValueStyle.Render(release.TagName))
if updateCheck {
cli.Print("\nRun %s to update\n", cli.ValueStyle.Render("core update --channel=dev"))
return nil
}
// Spawn watcher before applying update
if err := spawnWatcher(); err != nil {
cli.Print("%s Could not spawn restart watcher: %v\n", cli.DimStyle.Render("!"), err)
}
cli.Print("\n%s Downloading update...\n", cli.DimStyle.Render("→"))
downloadURL, err := GetDownloadURL(release, "")
if err != nil {
return cli.Wrap(err, "failed to get download URL")
}
if err := DoUpdate(downloadURL); err != nil {
return cli.Wrap(err, "failed to apply update")
}
cli.Print("%s Updated to %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), release.TagName)
cli.Print("%s Restarting...\n", cli.DimStyle.Render("→"))
return nil
}
// handleDevTagUpdate fetches the dev release using the direct tag
func handleDevTagUpdate(currentVersion string) error {
// Construct download URL directly for dev release
downloadURL := fmt.Sprintf(
"https://github.com/%s/%s/releases/download/dev/core-%s-%s",
repoOwner, repoName, runtime.GOOS, runtime.GOARCH,
)
if runtime.GOOS == "windows" {
downloadURL += ".exe"
}
cli.Print("%s dev (rolling)\n", cli.DimStyle.Render("Latest:"))
if updateCheck {
cli.Print("\nRun %s to update\n", cli.ValueStyle.Render("core update --channel=dev"))
return nil
}
// Spawn watcher before applying update
if err := spawnWatcher(); err != nil {
cli.Print("%s Could not spawn restart watcher: %v\n", cli.DimStyle.Render("!"), err)
}
cli.Print("\n%s Downloading from dev release...\n", cli.DimStyle.Render("→"))
if err := DoUpdate(downloadURL); err != nil {
return cli.Wrap(err, "failed to apply update")
}
cli.Print("%s Updated to latest dev build\n", cli.SuccessStyle.Render(cli.Glyph(":check:")))
cli.Print("%s Restarting...\n", cli.DimStyle.Render("→"))
return nil
}

View file

@ -1,68 +0,0 @@
//go:build !windows
package updater
import (
"os"
"os/exec"
"strconv"
"syscall"
"time"
)
// spawnWatcher spawns a background process that watches for the current process
// to exit, then restarts the binary with --version to confirm the update.
func spawnWatcher() error {
executable, err := os.Executable()
if err != nil {
return err
}
pid := os.Getpid()
// Spawn: core update --watch-pid=<pid>
cmd := exec.Command(executable, "update", "--watch-pid", strconv.Itoa(pid))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Detach from parent process group
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
return cmd.Start()
}
// watchAndRestart waits for the given PID to exit, then restarts the binary.
func watchAndRestart(pid int) error {
// Wait for the parent process to die
for isProcessRunning(pid) {
time.Sleep(100 * time.Millisecond)
}
// Small delay to ensure file handle is released
time.Sleep(200 * time.Millisecond)
// Get executable path
executable, err := os.Executable()
if err != nil {
return err
}
// Use exec to replace this process
return syscall.Exec(executable, []string{executable, "--version"}, os.Environ())
}
// isProcessRunning checks if a process with the given PID is still running.
func isProcessRunning(pid int) bool {
process, err := os.FindProcess(pid)
if err != nil {
return false
}
// On Unix, FindProcess always succeeds, so we need to send signal 0
// to check if the process actually exists
err = process.Signal(syscall.Signal(0))
return err == nil
}

View file

@ -1,76 +0,0 @@
//go:build windows
package updater
import (
"os"
"os/exec"
"strconv"
"syscall"
"time"
)
// spawnWatcher spawns a background process that watches for the current process
// to exit, then restarts the binary with --version to confirm the update.
func spawnWatcher() error {
executable, err := os.Executable()
if err != nil {
return err
}
pid := os.Getpid()
// Spawn: core update --watch-pid=<pid>
cmd := exec.Command(executable, "update", "--watch-pid", strconv.Itoa(pid))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// On Windows, use CREATE_NEW_PROCESS_GROUP to detach
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
}
return cmd.Start()
}
// watchAndRestart waits for the given PID to exit, then restarts the binary.
func watchAndRestart(pid int) error {
// Wait for the parent process to die
for {
if !isProcessRunning(pid) {
break
}
time.Sleep(100 * time.Millisecond)
}
// Small delay to ensure file handle is released
time.Sleep(500 * time.Millisecond)
// Get executable path
executable, err := os.Executable()
if err != nil {
return err
}
// On Windows, spawn new process and exit
cmd := exec.Command(executable, "--version")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return err
}
os.Exit(0)
return nil
}
// isProcessRunning checks if a process with the given PID is still running.
func isProcessRunning(pid int) bool {
// On Windows, try to open the process with query rights
handle, err := syscall.OpenProcess(syscall.PROCESS_QUERY_INFORMATION, false, uint32(pid))
if err != nil {
return false
}
syscall.CloseHandle(handle)
return true
}

View file

@ -1,9 +0,0 @@
# Documentation
Welcome to the documentation for the `updater` library. This library provides self-update functionality for Go applications, supporting both GitHub Releases and generic HTTP endpoints.
## Contents
* [Getting Started](getting-started.md): Installation and basic usage.
* [Configuration](configuration.md): Detailed configuration options for `UpdateService` and CLI flags.
* [Architecture](architecture.md): How the updater works, including GitHub integration and version comparison.

View file

@ -1,53 +0,0 @@
# Architecture
The `updater` library is designed to facilitate self-updates for Go applications by replacing the running binary with a newer version downloaded from a remote source.
## Update Mechanisms
The library supports two primary update sources:
1. **GitHub Releases:** Fetches releases directly from a GitHub repository.
2. **Generic HTTP:** Fetches update information from a generic HTTP endpoint.
### GitHub Releases
When configured with a GitHub repository URL (e.g., `https://github.com/owner/repo`), the updater uses the GitHub API to find releases.
* **Channel Support:** You can specify a "channel" (e.g., "stable", "beta"). The updater will filter releases based on this channel.
* Ideally, this maps to release tags or pre-release status (though the specific implementation details of how "channel" maps to GitHub release types should be verified in the code).
* **Pull Request Updates:** The library supports updating to a specific pull request artifact, useful for testing pre-release builds.
### Generic HTTP
When configured with a generic HTTP URL, the updater expects the endpoint to return a JSON object describing the latest version.
**Expected JSON Format:**
```json
{
"version": "1.2.3",
"url": "https://your-server.com/path/to/release-asset"
}
```
The updater compares the `version` from the JSON with the current application version. If the remote version is newer, it downloads the binary from the `url`.
## Version Comparison
The library uses Semantic Versioning (SemVer) to compare versions.
* **Prefix Handling:** The `ForceSemVerPrefix` configuration option allows you to standardize version tags by enforcing a `v` prefix (e.g., `v1.0.0` vs `1.0.0`) for consistent comparison.
* **Logic:**
* If `Remote Version` > `Current Version`: Update available.
* If `Remote Version` <= `Current Version`: Up to date.
## Self-Update Process
The actual update process is handled by the `minio/selfupdate` library.
1. **Download:** The new binary is downloaded from the source.
2. **Verification:** (Depending on configuration/implementation) Checksums may be verified.
3. **Apply:** The current executable file is replaced with the new binary.
* **Windows:** The old binary is renamed (often to `.old`) before replacement to allow the write operation.
* **Linux/macOS:** The file is unlinked and replaced.
4. **Restart:** The application usually needs to be restarted for the changes to take effect. The `updater` library currently handles the *replacement*, but the *restart* logic is typically left to the application.

View file

@ -1,34 +0,0 @@
# Configuration
The `updater` library is highly configurable via the `UpdateServiceConfig` struct.
## UpdateServiceConfig
When creating a new `UpdateService`, you pass a `UpdateServiceConfig` struct. Here are the available fields:
| Field | Type | Description |
| :--- | :--- | :--- |
| `RepoURL` | `string` | The URL to the repository for updates. Can be a GitHub repository URL (e.g., `https://github.com/owner/repo`) or a base URL for a generic HTTP update server. |
| `Channel` | `string` | Specifies the release channel to track (e.g., "stable", "prerelease"). This is **only used for GitHub-based updates**. |
| `CheckOnStartup` | `StartupCheckMode` | Determines the behavior when the service starts. See [Startup Modes](#startup-modes) below. |
| `ForceSemVerPrefix` | `bool` | Toggles whether to enforce a 'v' prefix on version tags for display and comparison. If `true`, a 'v' prefix is added if missing. |
| `ReleaseURLFormat` | `string` | A template for constructing the download URL for a release asset. The placeholder `{tag}` will be replaced with the release tag. |
### Startup Modes
The `CheckOnStartup` field can take one of the following values:
* `updater.NoCheck`: Disables any checks on startup.
* `updater.CheckOnStartup`: Checks for updates on startup but does not apply them.
* `updater.CheckAndUpdateOnStartup`: Checks for and applies updates on startup.
## CLI Flags
If you are using the example CLI provided in `cmd/updater`, the following flags are available:
* `--check-update`: Check for new updates without applying them.
* `--do-update`: Perform an update if available.
* `--channel`: Set the update channel (e.g., stable, beta, alpha). If not set, it's determined from the current version tag.
* `--force-semver-prefix`: Force 'v' prefix on semver tags (default `true`).
* `--release-url-format`: A URL format for release assets.
* `--pull-request`: Update to a specific pull request (integer ID).

View file

@ -1,85 +0,0 @@
# Getting Started
This guide will help you integrate the `updater` library into your Go application.
## Installation
To install the library, run:
```bash
go get github.com/snider/updater
```
## Basic Usage
The `updater` library provides an `UpdateService` that simplifies the process of checking for and applying updates.
### GitHub-based Updates
If you are hosting your releases on GitHub, you can configure the service to check your repository.
```go
package main
import (
"fmt"
"log"
"github.com/snider/updater"
)
func main() {
// Configure the update service
config := updater.UpdateServiceConfig{
RepoURL: "https://github.com/your-username/your-repo",
Channel: "stable", // or "beta", "alpha", etc.
CheckOnStartup: updater.CheckAndUpdateOnStartup,
}
// Create the service
updateService, err := updater.NewUpdateService(config)
if err != nil {
log.Fatalf("Failed to create update service: %v", err)
}
// Start the service (checks for updates and applies them if configured)
if err := updateService.Start(); err != nil {
fmt.Printf("Update check/apply failed: %v\n", err)
} else {
fmt.Println("Update check completed.")
}
}
```
### Generic HTTP Updates
If you are hosting your releases on a generic HTTP server, the server must provide a way to check for the latest version.
```go
package main
import (
"fmt"
"log"
"github.com/snider/updater"
)
func main() {
config := updater.UpdateServiceConfig{
RepoURL: "https://your-server.com/updates",
CheckOnStartup: updater.CheckOnStartup, // Check only, don't apply automatically
}
updateService, err := updater.NewUpdateService(config)
if err != nil {
log.Fatalf("Failed to create update service: %v", err)
}
if err := updateService.Start(); err != nil {
fmt.Printf("Update check failed: %v\n", err)
}
}
```
For Generic HTTP updates, the endpoint is expected to return a JSON object with `version` and `url` fields. See [Architecture](architecture.md) for more details.

View file

@ -1,55 +0,0 @@
package updater
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// GenericUpdateInfo holds the information from a latest.json file.
// This file is expected to be at the root of a generic HTTP update server.
type GenericUpdateInfo struct {
Version string `json:"version"` // The version number of the update.
URL string `json:"url"` // The URL to download the update from.
}
// GetLatestUpdateFromURL fetches and parses a latest.json file from a base URL.
// The server at the baseURL should host a 'latest.json' file that contains
// the version and download URL for the latest update.
//
// Example of latest.json:
//
// {
// "version": "1.2.3",
// "url": "https://your-server.com/path/to/release-asset"
// }
func GetLatestUpdateFromURL(baseURL string) (*GenericUpdateInfo, error) {
u, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
}
// Append latest.json to the path
u.Path += "/latest.json"
resp, err := http.Get(u.String())
if err != nil {
return nil, fmt.Errorf("failed to fetch latest.json: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch latest.json: status code %d", resp.StatusCode)
}
var info GenericUpdateInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, fmt.Errorf("failed to parse latest.json: %w", err)
}
if info.Version == "" || info.URL == "" {
return nil, fmt.Errorf("invalid latest.json content: version or url is missing")
}
return &info, nil
}

View file

@ -1,77 +0,0 @@
package updater
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestGetLatestUpdateFromURL(t *testing.T) {
testCases := []struct {
name string
handler http.HandlerFunc
expectError bool
expectedVersion string
expectedURL string
}{
{
name: "Valid latest.json",
handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, `{"version": "v1.1.0", "url": "http://example.com/release.zip"}`)
},
expectedVersion: "v1.1.0",
expectedURL: "http://example.com/release.zip",
},
{
name: "Invalid JSON",
handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, `{"version": "v1.1.0", "url": "http://example.com/release.zip"`) // Missing closing brace
},
expectError: true,
},
{
name: "Missing version",
handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, `{"url": "http://example.com/release.zip"}`)
},
expectError: true,
},
{
name: "Missing URL",
handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, `{"version": "v1.1.0"}`)
},
expectError: true,
},
{
name: "Server error",
handler: func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
},
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(tc.handler)
defer server.Close()
info, err := GetLatestUpdateFromURL(server.URL)
if (err != nil) != tc.expectError {
t.Errorf("Expected error: %v, got: %v", tc.expectError, err)
}
if !tc.expectError {
if info.Version != tc.expectedVersion {
t.Errorf("Expected version: %s, got: %s", tc.expectedVersion, info.Version)
}
if info.URL != tc.expectedURL {
t.Errorf("Expected URL: %s, got: %s", tc.expectedURL, info.URL)
}
}
})
}
}

View file

@ -1,302 +0,0 @@
package updater
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"runtime"
"strings"
"golang.org/x/oauth2"
)
// Repo represents a repository from the GitHub API.
type Repo struct {
CloneURL string `json:"clone_url"` // The URL to clone the repository.
}
// ReleaseAsset represents a single asset from a GitHub release.
type ReleaseAsset struct {
Name string `json:"name"` // The name of the asset.
DownloadURL string `json:"browser_download_url"` // The URL to download the asset.
}
// Release represents a GitHub release.
type Release struct {
TagName string `json:"tag_name"` // The name of the tag for the release.
PreRelease bool `json:"prerelease"` // Indicates if the release is a pre-release.
Assets []ReleaseAsset `json:"assets"` // A list of assets associated with the release.
}
// GithubClient defines the interface for interacting with the GitHub API.
// This allows for mocking the client in tests.
type GithubClient interface {
// GetPublicRepos fetches the public repositories for a user or organization.
GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error)
// GetLatestRelease fetches the latest release for a given repository and channel.
GetLatestRelease(ctx context.Context, owner, repo, channel string) (*Release, error)
// GetReleaseByPullRequest fetches a release associated with a specific pull request number.
GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) (*Release, error)
}
type githubClient struct{}
// NewAuthenticatedClient creates a new HTTP client that authenticates with the GitHub API.
// It uses the GITHUB_TOKEN environment variable for authentication.
// If the token is not set, it returns the default HTTP client.
var NewAuthenticatedClient = func(ctx context.Context) *http.Client {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
return http.DefaultClient
}
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
)
return oauth2.NewClient(ctx, ts)
}
func (g *githubClient) GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error) {
return g.getPublicReposWithAPIURL(ctx, "https://api.github.com", userOrOrg)
}
func (g *githubClient) getPublicReposWithAPIURL(ctx context.Context, apiURL, userOrOrg string) ([]string, error) {
client := NewAuthenticatedClient(ctx)
var allCloneURLs []string
url := fmt.Sprintf("%s/users/%s/repos", apiURL, userOrOrg)
for {
if err := ctx.Err(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Borg-Data-Collector")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
// Try organization endpoint
url = fmt.Sprintf("%s/orgs/%s/repos", apiURL, userOrOrg)
req, err = http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Borg-Data-Collector")
resp, err = client.Do(req)
if err != nil {
return nil, err
}
}
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
return nil, fmt.Errorf("failed to fetch repos: %s", resp.Status)
}
var repos []Repo
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
_ = resp.Body.Close()
return nil, err
}
_ = resp.Body.Close()
for _, repo := range repos {
allCloneURLs = append(allCloneURLs, repo.CloneURL)
}
linkHeader := resp.Header.Get("Link")
if linkHeader == "" {
break
}
nextURL := g.findNextURL(linkHeader)
if nextURL == "" {
break
}
url = nextURL
}
return allCloneURLs, nil
}
func (g *githubClient) findNextURL(linkHeader string) string {
links := strings.Split(linkHeader, ",")
for _, link := range links {
parts := strings.Split(link, ";")
if len(parts) == 2 && strings.TrimSpace(parts[1]) == `rel="next"` {
return strings.Trim(strings.TrimSpace(parts[0]), "<>")
}
}
return ""
}
// GetLatestRelease fetches the latest release for a given repository and channel.
// The channel can be "stable", "beta", or "alpha".
func (g *githubClient) GetLatestRelease(ctx context.Context, owner, repo, channel string) (*Release, error) {
client := NewAuthenticatedClient(ctx)
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Borg-Data-Collector")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch releases: %s", resp.Status)
}
var releases []Release
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, err
}
return filterReleases(releases, channel), nil
}
// filterReleases filters releases based on the specified channel.
func filterReleases(releases []Release, channel string) *Release {
for _, release := range releases {
releaseChannel := determineChannel(release.TagName, release.PreRelease)
if releaseChannel == channel {
return &release
}
}
return nil
}
// determineChannel determines the stability channel of a release based on its tag and PreRelease flag.
func determineChannel(tagName string, isPreRelease bool) string {
tagLower := strings.ToLower(tagName)
if strings.Contains(tagLower, "alpha") {
return "alpha"
}
if strings.Contains(tagLower, "beta") {
return "beta"
}
if isPreRelease { // A pre-release without alpha/beta is treated as beta
return "beta"
}
return "stable"
}
// GetReleaseByPullRequest fetches a release associated with a specific pull request number.
func (g *githubClient) GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) (*Release, error) {
client := NewAuthenticatedClient(ctx)
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Borg-Data-Collector")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch releases: %s", resp.Status)
}
var releases []Release
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, err
}
// The pr number is included in the tag name with the format `vX.Y.Z-alpha.pr.123` or `vX.Y.Z-beta.pr.123`
prTagSuffix := fmt.Sprintf(".pr.%d", prNumber)
for _, release := range releases {
if strings.Contains(release.TagName, prTagSuffix) {
return &release, nil
}
}
return nil, nil // No release found for the given PR number
}
// GetDownloadURL finds the appropriate download URL for the current operating system and architecture.
//
// It supports two modes of operation:
// 1. Using a 'releaseURLFormat' template: If 'releaseURLFormat' is provided,
// it will be used to construct the download URL. The template can contain
// placeholders for the release tag '{tag}', operating system '{os}', and
// architecture '{arch}'.
// 2. Automatic detection: If 'releaseURLFormat' is empty, the function will
// inspect the assets of the release to find a suitable download URL. It
// searches for an asset name that contains both the current OS and architecture
// (e.g., "my-app-linux-amd64"). If no match is found, it falls back to
// matching only the OS.
//
// Example with releaseURLFormat:
//
// release := &updater.Release{TagName: "v1.2.3"}
// url, err := updater.GetDownloadURL(release, "https://example.com/downloads/{tag}/{os}/{arch}")
// if err != nil {
// // handle error
// }
// fmt.Println(url) // "https://example.com/downloads/v1.2.3/linux/amd64" (on a Linux AMD64 system)
//
// Example with automatic detection:
//
// release := &updater.Release{
// Assets: []updater.ReleaseAsset{
// {Name: "my-app-linux-amd64", DownloadURL: "https://example.com/download/linux-amd64"},
// {Name: "my-app-windows-amd64", DownloadURL: "https://example.com/download/windows-amd64"},
// },
// }
// url, err := updater.GetDownloadURL(release, "")
// if err != nil {
// // handle error
// }
// fmt.Println(url) // "https://example.com/download/linux-amd64" (on a Linux AMD64 system)
func GetDownloadURL(release *Release, releaseURLFormat string) (string, error) {
if release == nil {
return "", fmt.Errorf("no release provided")
}
if releaseURLFormat != "" {
// Replace {tag}, {os}, and {arch} placeholders
r := strings.NewReplacer(
"{tag}", release.TagName,
"{os}", runtime.GOOS,
"{arch}", runtime.GOARCH,
)
return r.Replace(releaseURLFormat), nil
}
osName := runtime.GOOS
archName := runtime.GOARCH
for _, asset := range release.Assets {
assetNameLower := strings.ToLower(asset.Name)
// Match asset that contains both OS and architecture
if strings.Contains(assetNameLower, osName) && strings.Contains(assetNameLower, archName) {
return asset.DownloadURL, nil
}
}
// Fallback for OS only if no asset matched both OS and arch
for _, asset := range release.Assets {
assetNameLower := strings.ToLower(asset.Name)
if strings.Contains(assetNameLower, osName) {
return asset.DownloadURL, nil
}
}
return "", fmt.Errorf("no suitable download asset found for %s/%s", osName, archName)
}

View file

@ -1,124 +0,0 @@
package updater
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"testing"
"github.com/Snider/Borg/pkg/mocks"
)
func TestGetPublicRepos(t *testing.T) {
mockClient := mocks.NewMockClient(map[string]*http.Response{
"https://api.github.com/users/testuser/repos": {
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString(`[{"clone_url": "https://github.com/testuser/repo1.git"}]`)),
},
"https://api.github.com/orgs/testorg/repos": {
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}, "Link": []string{`<https://api.github.com/organizations/123/repos?page=2>; rel="next"`}},
Body: io.NopCloser(bytes.NewBufferString(`[{"clone_url": "https://github.com/testorg/repo1.git"}]`)),
},
"https://api.github.com/organizations/123/repos?page=2": {
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString(`[{"clone_url": "https://github.com/testorg/repo2.git"}]`)),
},
})
client := &githubClient{}
oldClient := NewAuthenticatedClient
NewAuthenticatedClient = func(ctx context.Context) *http.Client {
return mockClient
}
defer func() {
NewAuthenticatedClient = oldClient
}()
// Test user repos
repos, err := client.getPublicReposWithAPIURL(context.Background(), "https://api.github.com", "testuser")
if err != nil {
t.Fatalf("getPublicReposWithAPIURL for user failed: %v", err)
}
if len(repos) != 1 || repos[0] != "https://github.com/testuser/repo1.git" {
t.Errorf("unexpected user repos: %v", repos)
}
// Test org repos with pagination
repos, err = client.getPublicReposWithAPIURL(context.Background(), "https://api.github.com", "testorg")
if err != nil {
t.Fatalf("getPublicReposWithAPIURL for org failed: %v", err)
}
if len(repos) != 2 || repos[0] != "https://github.com/testorg/repo1.git" || repos[1] != "https://github.com/testorg/repo2.git" {
t.Errorf("unexpected org repos: %v", repos)
}
}
func TestGetPublicRepos_Error(t *testing.T) {
u, _ := url.Parse("https://api.github.com/users/testuser/repos")
mockClient := mocks.NewMockClient(map[string]*http.Response{
"https://api.github.com/users/testuser/repos": {
StatusCode: http.StatusNotFound,
Status: "404 Not Found",
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString("")),
Request: &http.Request{Method: "GET", URL: u},
},
"https://api.github.com/orgs/testuser/repos": {
StatusCode: http.StatusNotFound,
Status: "404 Not Found",
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString("")),
Request: &http.Request{Method: "GET", URL: u},
},
})
expectedErr := "failed to fetch repos: 404 Not Found"
client := &githubClient{}
oldClient := NewAuthenticatedClient
NewAuthenticatedClient = func(ctx context.Context) *http.Client {
return mockClient
}
defer func() {
NewAuthenticatedClient = oldClient
}()
// Test user repos
_, err := client.getPublicReposWithAPIURL(context.Background(), "https://api.github.com", "testuser")
if err.Error() != expectedErr {
t.Fatalf("getPublicReposWithAPIURL for user failed: %v", err)
}
}
func TestFindNextURL(t *testing.T) {
client := &githubClient{}
linkHeader := `<https://api.github.com/organizations/123/repos?page=2>; rel="next", <https://api.github.com/organizations/123/repos?page=1>; rel="prev"`
nextURL := client.findNextURL(linkHeader)
if nextURL != "https://api.github.com/organizations/123/repos?page=2" {
t.Errorf("unexpected next URL: %s", nextURL)
}
linkHeader = `<https://api.github.com/organizations/123/repos?page=1>; rel="prev"`
nextURL = client.findNextURL(linkHeader)
if nextURL != "" {
t.Errorf("unexpected next URL: %s", nextURL)
}
}
func TestNewAuthenticatedClient(t *testing.T) {
// Test with no token
client := NewAuthenticatedClient(context.Background())
if client != http.DefaultClient {
t.Errorf("expected http.DefaultClient, but got something else")
}
// Test with token
t.Setenv("GITHUB_TOKEN", "test-token")
client = NewAuthenticatedClient(context.Background())
if client == http.DefaultClient {
t.Errorf("expected an authenticated client, but got http.DefaultClient")
}
}

View file

@ -1,36 +0,0 @@
package updater
import (
"context"
)
// MockGithubClient is a mock implementation of the GithubClient interface for testing.
type MockGithubClient struct {
GetLatestReleaseFunc func(ctx context.Context, owner, repo, channel string) (*Release, error)
GetReleaseByPullRequestFunc func(ctx context.Context, owner, repo string, prNumber int) (*Release, error)
GetPublicReposFunc func(ctx context.Context, userOrOrg string) ([]string, error)
}
// GetLatestRelease mocks the GetLatestRelease method of the GithubClient interface.
func (m *MockGithubClient) GetLatestRelease(ctx context.Context, owner, repo, channel string) (*Release, error) {
if m.GetLatestReleaseFunc != nil {
return m.GetLatestReleaseFunc(ctx, owner, repo, channel)
}
return nil, nil
}
// GetReleaseByPullRequest mocks the GetReleaseByPullRequest method of the GithubClient interface.
func (m *MockGithubClient) GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) (*Release, error) {
if m.GetReleaseByPullRequestFunc != nil {
return m.GetReleaseByPullRequestFunc(ctx, owner, repo, prNumber)
}
return nil, nil
}
// GetPublicRepos mocks the GetPublicRepos method of the GithubClient interface.
func (m *MockGithubClient) GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error) {
if m.GetPublicReposFunc != nil {
return m.GetPublicReposFunc(ctx, userOrOrg)
}
return []string{"repo1", "repo2"}, nil
}

View file

@ -1,4 +0,0 @@
{
"name": "updater",
"version": "1.2.3"
}

View file

@ -1,127 +0,0 @@
//go:generate go run forge.lthn.ai/core/cli/cmd/updater/build
// Package updater provides functionality for self-updating Go applications.
// It supports updates from GitHub releases and generic HTTP endpoints.
package updater
import (
"fmt"
"net/url"
"strings"
)
// StartupCheckMode defines the updater's behavior on startup.
type StartupCheckMode int
const (
// NoCheck disables any checks on startup.
NoCheck StartupCheckMode = iota
// CheckOnStartup checks for updates on startup but does not apply them.
CheckOnStartup
// CheckAndUpdateOnStartup checks for and applies updates on startup.
CheckAndUpdateOnStartup
)
// UpdateServiceConfig holds the configuration for the UpdateService.
type UpdateServiceConfig struct {
// RepoURL is the URL to the repository for updates. It can be a GitHub
// repository URL (e.g., "https://github.com/owner/repo") or a base URL
// for a generic HTTP update server.
RepoURL string
// Channel specifies the release channel to track (e.g., "stable", "prerelease").
// This is only used for GitHub-based updates.
Channel string
// CheckOnStartup determines the update behavior when the service starts.
CheckOnStartup StartupCheckMode
// ForceSemVerPrefix toggles whether to enforce a 'v' prefix on version tags for display.
// If true, a 'v' prefix is added if missing. If false, it's removed if present.
ForceSemVerPrefix bool
// ReleaseURLFormat provides a template for constructing the download URL for a
// release asset. The placeholder {tag} will be replaced with the release tag.
ReleaseURLFormat string
}
// UpdateService provides a configurable interface for handling application updates.
// It can be configured to check for updates on startup and, if desired, apply
// them automatically. The service can handle updates from both GitHub releases
// and generic HTTP servers.
type UpdateService struct {
config UpdateServiceConfig
isGitHub bool
owner string
repo string
}
// NewUpdateService creates and configures a new UpdateService.
// It parses the repository URL to determine if it's a GitHub repository
// and extracts the owner and repo name.
func NewUpdateService(config UpdateServiceConfig) (*UpdateService, error) {
isGitHub := strings.Contains(config.RepoURL, "github.com")
var owner, repo string
var err error
if isGitHub {
owner, repo, err = ParseRepoURL(config.RepoURL)
if err != nil {
return nil, fmt.Errorf("failed to parse GitHub repo URL: %w", err)
}
}
return &UpdateService{
config: config,
isGitHub: isGitHub,
owner: owner,
repo: repo,
}, nil
}
// Start initiates the update check based on the service configuration.
// It determines whether to perform a GitHub or HTTP-based update check
// based on the RepoURL. The behavior of the check is controlled by the
// CheckOnStartup setting in the configuration.
func (s *UpdateService) Start() error {
if s.isGitHub {
return s.startGitHubCheck()
}
return s.startHTTPCheck()
}
func (s *UpdateService) startGitHubCheck() error {
switch s.config.CheckOnStartup {
case NoCheck:
return nil // Do nothing
case CheckOnStartup:
return CheckOnly(s.owner, s.repo, s.config.Channel, s.config.ForceSemVerPrefix, s.config.ReleaseURLFormat)
case CheckAndUpdateOnStartup:
return CheckForUpdates(s.owner, s.repo, s.config.Channel, s.config.ForceSemVerPrefix, s.config.ReleaseURLFormat)
default:
return fmt.Errorf("unknown startup check mode: %d", s.config.CheckOnStartup)
}
}
func (s *UpdateService) startHTTPCheck() error {
switch s.config.CheckOnStartup {
case NoCheck:
return nil // Do nothing
case CheckOnStartup:
return CheckOnlyHTTP(s.config.RepoURL)
case CheckAndUpdateOnStartup:
return CheckForUpdatesHTTP(s.config.RepoURL)
default:
return fmt.Errorf("unknown startup check mode: %d", s.config.CheckOnStartup)
}
}
// ParseRepoURL extracts the owner and repository name from a GitHub URL.
// It handles standard GitHub URL formats.
func ParseRepoURL(repoURL string) (owner string, repo string, err error) {
u, err := url.Parse(repoURL)
if err != nil {
return "", "", err
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) < 2 {
return "", "", fmt.Errorf("invalid repo URL path: %s", u.Path)
}
return parts[0], parts[1], nil
}

View file

@ -1,42 +0,0 @@
package updater_test
import (
"fmt"
"log"
"forge.lthn.ai/core/cli/cmd/updater"
)
func ExampleNewUpdateService() {
// Mock the update check functions to prevent actual updates during tests
updater.CheckForUpdates = func(owner, repo, channel string, forceSemVerPrefix bool, releaseURLFormat string) error {
fmt.Println("CheckForUpdates called")
return nil
}
defer func() {
updater.CheckForUpdates = nil // Restore original function
}()
config := updater.UpdateServiceConfig{
RepoURL: "https://github.com/owner/repo",
Channel: "stable",
CheckOnStartup: updater.CheckAndUpdateOnStartup,
}
updateService, err := updater.NewUpdateService(config)
if err != nil {
log.Fatalf("Failed to create update service: %v", err)
}
if err := updateService.Start(); err != nil {
log.Printf("Update check failed: %v", err)
}
// Output: CheckForUpdates called
}
func ExampleParseRepoURL() {
owner, repo, err := updater.ParseRepoURL("https://github.com/owner/repo")
if err != nil {
log.Fatalf("Failed to parse repo URL: %v", err)
}
fmt.Printf("Owner: %s, Repo: %s", owner, repo)
// Output: Owner: owner, Repo: repo
}

View file

@ -1,170 +0,0 @@
package updater
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestNewUpdateService(t *testing.T) {
testCases := []struct {
name string
config UpdateServiceConfig
expectError bool
isGitHub bool
}{
{
name: "Valid GitHub URL",
config: UpdateServiceConfig{
RepoURL: "https://github.com/owner/repo",
},
isGitHub: true,
},
{
name: "Valid non-GitHub URL",
config: UpdateServiceConfig{
RepoURL: "https://example.com/updates",
},
isGitHub: false,
},
{
name: "Invalid GitHub URL",
config: UpdateServiceConfig{
RepoURL: "https://github.com/owner",
},
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
service, err := NewUpdateService(tc.config)
if (err != nil) != tc.expectError {
t.Errorf("Expected error: %v, got: %v", tc.expectError, err)
}
if err == nil && service.isGitHub != tc.isGitHub {
t.Errorf("Expected isGitHub: %v, got: %v", tc.isGitHub, service.isGitHub)
}
})
}
}
func TestUpdateService_Start(t *testing.T) {
// Setup a mock server for HTTP tests
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"version": "v1.1.0", "url": "http://example.com/release.zip"}`))
}))
defer server.Close()
testCases := []struct {
name string
config UpdateServiceConfig
checkOnlyGitHub int
checkAndDoGitHub int
checkOnlyHTTPCalls int
checkAndDoHTTPCalls int
expectError bool
}{
{
name: "GitHub: NoCheck",
config: UpdateServiceConfig{
RepoURL: "https://github.com/owner/repo",
CheckOnStartup: NoCheck,
},
},
{
name: "GitHub: CheckOnStartup",
config: UpdateServiceConfig{
RepoURL: "https://github.com/owner/repo",
CheckOnStartup: CheckOnStartup,
},
checkOnlyGitHub: 1,
},
{
name: "GitHub: CheckAndUpdateOnStartup",
config: UpdateServiceConfig{
RepoURL: "https://github.com/owner/repo",
CheckOnStartup: CheckAndUpdateOnStartup,
},
checkAndDoGitHub: 1,
},
{
name: "HTTP: NoCheck",
config: UpdateServiceConfig{
RepoURL: server.URL,
CheckOnStartup: NoCheck,
},
},
{
name: "HTTP: CheckOnStartup",
config: UpdateServiceConfig{
RepoURL: server.URL,
CheckOnStartup: CheckOnStartup,
},
checkOnlyHTTPCalls: 1,
},
{
name: "HTTP: CheckAndUpdateOnStartup",
config: UpdateServiceConfig{
RepoURL: server.URL,
CheckOnStartup: CheckAndUpdateOnStartup,
},
checkAndDoHTTPCalls: 1,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var checkOnlyGitHub, checkAndDoGitHub, checkOnlyHTTP, checkAndDoHTTP int
// Mock GitHub functions
originalCheckOnly := CheckOnly
CheckOnly = func(owner, repo, channel string, forceSemVerPrefix bool, releaseURLFormat string) error {
checkOnlyGitHub++
return nil
}
defer func() { CheckOnly = originalCheckOnly }()
originalCheckForUpdates := CheckForUpdates
CheckForUpdates = func(owner, repo, channel string, forceSemVerPrefix bool, releaseURLFormat string) error {
checkAndDoGitHub++
return nil
}
defer func() { CheckForUpdates = originalCheckForUpdates }()
// Mock HTTP functions
originalCheckOnlyHTTP := CheckOnlyHTTP
CheckOnlyHTTP = func(baseURL string) error {
checkOnlyHTTP++
return nil
}
defer func() { CheckOnlyHTTP = originalCheckOnlyHTTP }()
originalCheckForUpdatesHTTP := CheckForUpdatesHTTP
CheckForUpdatesHTTP = func(baseURL string) error {
checkAndDoHTTP++
return nil
}
defer func() { CheckForUpdatesHTTP = originalCheckForUpdatesHTTP }()
service, _ := NewUpdateService(tc.config)
err := service.Start()
if (err != nil) != tc.expectError {
t.Errorf("Expected error: %v, got: %v", tc.expectError, err)
}
if checkOnlyGitHub != tc.checkOnlyGitHub {
t.Errorf("Expected GitHub CheckOnly calls: %d, got: %d", tc.checkOnlyGitHub, checkOnlyGitHub)
}
if checkAndDoGitHub != tc.checkAndDoGitHub {
t.Errorf("Expected GitHub CheckForUpdates calls: %d, got: %d", tc.checkAndDoGitHub, checkAndDoGitHub)
}
if checkOnlyHTTP != tc.checkOnlyHTTPCalls {
t.Errorf("Expected HTTP CheckOnly calls: %d, got: %d", tc.checkOnlyHTTPCalls, checkOnlyHTTP)
}
if checkAndDoHTTP != tc.checkAndDoHTTPCalls {
t.Errorf("Expected HTTP CheckForUpdates calls: %d, got: %d", tc.checkAndDoHTTPCalls, checkAndDoHTTP)
}
})
}
}

View file

@ -1,17 +0,0 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

View file

@ -1,43 +0,0 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

View file

@ -1,4 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View file

@ -1,20 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

View file

@ -1,42 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

View file

@ -1,59 +0,0 @@
# CoreElementTemplate
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.9.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View file

@ -1,81 +0,0 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"core-element-template": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"standalone": false
},
"@schematics/angular:directive": {
"standalone": false
},
"@schematics/angular:pipe": {
"standalone": false
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "none"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "core-element-template:build:production"
},
"development": {
"buildTarget": "core-element-template:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,49 +0,0 @@
{
"name": "core-element-template",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/elements": "^20.3.10",
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^20.3.9",
"@angular/cli": "^20.3.9",
"@angular/compiler-cli": "^20.3.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.9.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.2"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View file

@ -1,23 +0,0 @@
import { DoBootstrap, Injector, NgModule, provideBrowserGlobalErrorListeners } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { createCustomElement } from '@angular/elements';
import { App } from './app';
@NgModule({
imports: [
BrowserModule,
App
],
providers: [
provideBrowserGlobalErrorListeners()
]
})
export class AppModule implements DoBootstrap {
constructor(private injector: Injector) {
const el = createCustomElement(App, { injector });
customElements.define('core-element-template', el);
}
ngDoBootstrap() {}
}

View file

@ -1 +0,0 @@
<h1>Hello, {{ title() }}</h1>

View file

@ -1,10 +0,0 @@
import { Component, signal } from '@angular/core';
@Component({
selector: 'core-element-template',
templateUrl: './app.html',
standalone: true
})
export class App {
protected readonly title = signal('core-element-template');
}

View file

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>CoreElementTemplate</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<core-element-template></core-element-template>
</body>
</html>

View file

@ -1,7 +0,0 @@
import { platformBrowser } from '@angular/platform-browser';
import { AppModule } from './app/app-module';
platformBrowser().bootstrapModule(AppModule, {
ngZoneEventCoalescing: true,
})
.catch(err => console.error(err));

View file

@ -1 +0,0 @@
/* You can add global styles to this file, and also import other style files */

View file

@ -1,15 +0,0 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

View file

@ -1,34 +0,0 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View file

@ -1,14 +0,0 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.ts"
]
}

View file

@ -1,237 +0,0 @@
package updater
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/minio/selfupdate"
"golang.org/x/mod/semver"
)
// Version holds the current version of the application.
// It is set at build time via ldflags or fallback to the version in package.json.
var Version = PkgVersion
// NewGithubClient is a variable that holds a function to create a new GithubClient.
// This can be replaced in tests to inject a mock client.
//
// Example:
//
// updater.NewGithubClient = func() updater.GithubClient {
// return &mockClient{} // or your mock implementation
// }
var NewGithubClient = func() GithubClient {
return &githubClient{}
}
// DoUpdate is a variable that holds the function to perform the actual update.
// This can be replaced in tests to prevent actual updates.
var DoUpdate = func(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Printf("failed to close response body: %v\n", err)
}
}(resp.Body)
err = selfupdate.Apply(resp.Body, selfupdate.Options{})
if err != nil {
if rerr := selfupdate.RollbackError(err); rerr != nil {
return fmt.Errorf("failed to rollback from failed update: %v", rerr)
}
return fmt.Errorf("update failed: %v", err)
}
fmt.Println("Update applied successfully.")
return nil
}
// CheckForNewerVersion checks if a newer version of the application is available on GitHub.
// It fetches the latest release for the given owner, repository, and channel, and compares its tag
// with the current application version.
var CheckForNewerVersion = func(owner, repo, channel string, forceSemVerPrefix bool) (*Release, bool, error) {
client := NewGithubClient()
ctx := context.Background()
release, err := client.GetLatestRelease(ctx, owner, repo, channel)
if err != nil {
return nil, false, fmt.Errorf("error fetching latest release: %w", err)
}
if release == nil {
return nil, false, nil // No release found
}
// Always normalize to 'v' prefix for semver comparison
vCurrent := formatVersionForComparison(Version)
vLatest := formatVersionForComparison(release.TagName)
if semver.Compare(vCurrent, vLatest) >= 0 {
return release, false, nil // Current version is up-to-date or newer
}
return release, true, nil // A newer version is available
}
// CheckForUpdates checks for new updates on GitHub and applies them if a newer version is found.
// It uses the provided owner, repository, and channel to find the latest release.
var CheckForUpdates = func(owner, repo, channel string, forceSemVerPrefix bool, releaseURLFormat string) error {
release, updateAvailable, err := CheckForNewerVersion(owner, repo, channel, forceSemVerPrefix)
if err != nil {
return err
}
if !updateAvailable {
if release != nil {
fmt.Printf("Current version %s is up-to-date with latest release %s.\n",
formatVersionForDisplay(Version, forceSemVerPrefix),
formatVersionForDisplay(release.TagName, forceSemVerPrefix))
} else {
fmt.Println("No releases found.")
}
return nil
}
fmt.Printf("Newer version %s found (current: %s). Applying update...\n",
formatVersionForDisplay(release.TagName, forceSemVerPrefix),
formatVersionForDisplay(Version, forceSemVerPrefix))
downloadURL, err := GetDownloadURL(release, releaseURLFormat)
if err != nil {
return fmt.Errorf("error getting download URL: %w", err)
}
return DoUpdate(downloadURL)
}
// CheckOnly checks for new updates on GitHub without applying them.
// It prints a message indicating if a new release is available.
var CheckOnly = func(owner, repo, channel string, forceSemVerPrefix bool, releaseURLFormat string) error {
release, updateAvailable, err := CheckForNewerVersion(owner, repo, channel, forceSemVerPrefix)
if err != nil {
return err
}
if !updateAvailable {
if release != nil {
fmt.Printf("Current version %s is up-to-date with latest release %s.\n",
formatVersionForDisplay(Version, forceSemVerPrefix),
formatVersionForDisplay(release.TagName, forceSemVerPrefix))
} else {
fmt.Println("No new release found.")
}
return nil
}
fmt.Printf("New release found: %s (current version: %s)\n",
formatVersionForDisplay(release.TagName, forceSemVerPrefix),
formatVersionForDisplay(Version, forceSemVerPrefix))
return nil
}
// CheckForUpdatesByTag checks for and applies updates from GitHub based on the channel
// determined by the current application's version tag (e.g., 'stable' or 'prerelease').
var CheckForUpdatesByTag = func(owner, repo string) error {
channel := determineChannel(Version, false) // isPreRelease is false for current version
return CheckForUpdates(owner, repo, channel, true, "")
}
// CheckOnlyByTag checks for updates from GitHub based on the channel determined by the
// current version tag, without applying them.
var CheckOnlyByTag = func(owner, repo string) error {
channel := determineChannel(Version, false) // isPreRelease is false for current version
return CheckOnly(owner, repo, channel, true, "")
}
// CheckForUpdatesByPullRequest finds a release associated with a specific pull request number
// on GitHub and applies the update.
var CheckForUpdatesByPullRequest = func(owner, repo string, prNumber int, releaseURLFormat string) error {
client := NewGithubClient()
ctx := context.Background()
release, err := client.GetReleaseByPullRequest(ctx, owner, repo, prNumber)
if err != nil {
return fmt.Errorf("error fetching release for pull request: %w", err)
}
if release == nil {
fmt.Printf("No release found for PR #%d.\n", prNumber)
return nil
}
fmt.Printf("Release %s found for PR #%d. Applying update...\n", release.TagName, prNumber)
downloadURL, err := GetDownloadURL(release, releaseURLFormat)
if err != nil {
return fmt.Errorf("error getting download URL: %w", err)
}
return DoUpdate(downloadURL)
}
// CheckForUpdatesHTTP checks for and applies updates from a generic HTTP endpoint.
// The endpoint is expected to provide update information in a structured format.
var CheckForUpdatesHTTP = func(baseURL string) error {
info, err := GetLatestUpdateFromURL(baseURL)
if err != nil {
return err
}
vCurrent := formatVersionForComparison(Version)
vLatest := formatVersionForComparison(info.Version)
if semver.Compare(vCurrent, vLatest) >= 0 {
fmt.Printf("Current version %s is up-to-date with latest release %s.\n", Version, info.Version)
return nil
}
fmt.Printf("Newer version %s found (current: %s). Applying update...\n", info.Version, Version)
return DoUpdate(info.URL)
}
// CheckOnlyHTTP checks for updates from a generic HTTP endpoint without applying them.
// It prints a message if a new version is available.
var CheckOnlyHTTP = func(baseURL string) error {
info, err := GetLatestUpdateFromURL(baseURL)
if err != nil {
return err
}
vCurrent := formatVersionForComparison(Version)
vLatest := formatVersionForComparison(info.Version)
if semver.Compare(vCurrent, vLatest) >= 0 {
fmt.Printf("Current version %s is up-to-date with latest release %s.\n", Version, info.Version)
return nil
}
fmt.Printf("New release found: %s (current version: %s)\n", info.Version, Version)
return nil
}
// formatVersionForComparison ensures the version string has a 'v' prefix for semver comparison.
func formatVersionForComparison(version string) string {
if version != "" && !strings.HasPrefix(version, "v") {
return "v" + version
}
return version
}
// formatVersionForDisplay ensures the version string has the correct 'v' prefix based on the forceSemVerPrefix flag.
func formatVersionForDisplay(version string, forceSemVerPrefix bool) string {
hasV := strings.HasPrefix(version, "v")
if forceSemVerPrefix && !hasV {
return "v" + version
}
if !forceSemVerPrefix && hasV {
return strings.TrimPrefix(version, "v")
}
return version
}

View file

@ -1,261 +0,0 @@
package updater
import (
"context"
"fmt"
"log"
"net/http"
"net/http/httptest"
"runtime"
)
// mockGithubClient is a mock implementation of the GithubClient interface for testing.
type mockGithubClient struct {
getLatestRelease func(ctx context.Context, owner, repo, channel string) (*Release, error)
getReleaseByPR func(ctx context.Context, owner, repo string, prNumber int) (*Release, error)
getPublicRepos func(ctx context.Context, userOrOrg string) ([]string, error)
getLatestReleaseCount int
getReleaseByPRCount int
getPublicReposCount int
}
func (m *mockGithubClient) GetLatestRelease(ctx context.Context, owner, repo, channel string) (*Release, error) {
m.getLatestReleaseCount++
return m.getLatestRelease(ctx, owner, repo, channel)
}
func (m *mockGithubClient) GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) (*Release, error) {
m.getReleaseByPRCount++
return m.getReleaseByPR(ctx, owner, repo, prNumber)
}
func (m *mockGithubClient) GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error) {
m.getPublicReposCount++
if m.getPublicRepos != nil {
return m.getPublicRepos(ctx, userOrOrg)
}
return nil, fmt.Errorf("GetPublicRepos not implemented")
}
func ExampleCheckForNewerVersion() {
originalNewGithubClient := NewGithubClient
defer func() { NewGithubClient = originalNewGithubClient }()
NewGithubClient = func() GithubClient {
return &mockGithubClient{
getLatestRelease: func(ctx context.Context, owner, repo, channel string) (*Release, error) {
return &Release{TagName: "v1.1.0"}, nil
},
}
}
Version = "1.0.0"
release, available, err := CheckForNewerVersion("owner", "repo", "stable", true)
if err != nil {
log.Fatalf("CheckForNewerVersion failed: %v", err)
}
if available {
fmt.Printf("Newer version available: %s", release.TagName)
} else {
fmt.Println("No newer version available.")
}
// Output: Newer version available: v1.1.0
}
func ExampleCheckForUpdates() {
// Mock the functions to prevent actual updates and network calls
originalDoUpdate := DoUpdate
originalNewGithubClient := NewGithubClient
defer func() {
DoUpdate = originalDoUpdate
NewGithubClient = originalNewGithubClient
}()
NewGithubClient = func() GithubClient {
return &mockGithubClient{
getLatestRelease: func(ctx context.Context, owner, repo, channel string) (*Release, error) {
return &Release{
TagName: "v1.1.0",
Assets: []ReleaseAsset{{Name: fmt.Sprintf("test-asset-%s-%s", runtime.GOOS, runtime.GOARCH), DownloadURL: "http://example.com/asset"}},
}, nil
},
}
}
DoUpdate = func(url string) error {
fmt.Printf("Update would be applied from: %s", url)
return nil
}
Version = "1.0.0"
err := CheckForUpdates("owner", "repo", "stable", true, "")
if err != nil {
log.Fatalf("CheckForUpdates failed: %v", err)
}
// Output:
// Newer version v1.1.0 found (current: v1.0.0). Applying update...
// Update would be applied from: http://example.com/asset
}
func ExampleCheckOnly() {
originalNewGithubClient := NewGithubClient
defer func() { NewGithubClient = originalNewGithubClient }()
NewGithubClient = func() GithubClient {
return &mockGithubClient{
getLatestRelease: func(ctx context.Context, owner, repo, channel string) (*Release, error) {
return &Release{TagName: "v1.1.0"}, nil
},
}
}
Version = "1.0.0"
err := CheckOnly("owner", "repo", "stable", true, "")
if err != nil {
log.Fatalf("CheckOnly failed: %v", err)
}
// Output: New release found: v1.1.0 (current version: v1.0.0)
}
func ExampleCheckForUpdatesByTag() {
// Mock the functions to prevent actual updates and network calls
originalDoUpdate := DoUpdate
originalNewGithubClient := NewGithubClient
defer func() {
DoUpdate = originalDoUpdate
NewGithubClient = originalNewGithubClient
}()
NewGithubClient = func() GithubClient {
return &mockGithubClient{
getLatestRelease: func(ctx context.Context, owner, repo, channel string) (*Release, error) {
if channel == "stable" {
return &Release{
TagName: "v1.1.0",
Assets: []ReleaseAsset{{Name: fmt.Sprintf("test-asset-%s-%s", runtime.GOOS, runtime.GOARCH), DownloadURL: "http://example.com/asset"}},
}, nil
}
return nil, nil
},
}
}
DoUpdate = func(url string) error {
fmt.Printf("Update would be applied from: %s", url)
return nil
}
Version = "1.0.0" // A version that resolves to the "stable" channel
err := CheckForUpdatesByTag("owner", "repo")
if err != nil {
log.Fatalf("CheckForUpdatesByTag failed: %v", err)
}
// Output:
// Newer version v1.1.0 found (current: v1.0.0). Applying update...
// Update would be applied from: http://example.com/asset
}
func ExampleCheckOnlyByTag() {
originalNewGithubClient := NewGithubClient
defer func() { NewGithubClient = originalNewGithubClient }()
NewGithubClient = func() GithubClient {
return &mockGithubClient{
getLatestRelease: func(ctx context.Context, owner, repo, channel string) (*Release, error) {
if channel == "stable" {
return &Release{TagName: "v1.1.0"}, nil
}
return nil, nil
},
}
}
Version = "1.0.0" // A version that resolves to the "stable" channel
err := CheckOnlyByTag("owner", "repo")
if err != nil {
log.Fatalf("CheckOnlyByTag failed: %v", err)
}
// Output: New release found: v1.1.0 (current version: v1.0.0)
}
func ExampleCheckForUpdatesByPullRequest() {
// Mock the functions to prevent actual updates and network calls
originalDoUpdate := DoUpdate
originalNewGithubClient := NewGithubClient
defer func() {
DoUpdate = originalDoUpdate
NewGithubClient = originalNewGithubClient
}()
NewGithubClient = func() GithubClient {
return &mockGithubClient{
getReleaseByPR: func(ctx context.Context, owner, repo string, prNumber int) (*Release, error) {
if prNumber == 123 {
return &Release{
TagName: "v1.1.0-alpha.pr.123",
Assets: []ReleaseAsset{{Name: fmt.Sprintf("test-asset-%s-%s", runtime.GOOS, runtime.GOARCH), DownloadURL: "http://example.com/asset-pr"}},
}, nil
}
return nil, nil
},
}
}
DoUpdate = func(url string) error {
fmt.Printf("Update would be applied from: %s", url)
return nil
}
err := CheckForUpdatesByPullRequest("owner", "repo", 123, "")
if err != nil {
log.Fatalf("CheckForUpdatesByPullRequest failed: %v", err)
}
// Output:
// Release v1.1.0-alpha.pr.123 found for PR #123. Applying update...
// Update would be applied from: http://example.com/asset-pr
}
func ExampleCheckForUpdatesHTTP() {
// Create a mock HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/latest.json" {
_, _ = fmt.Fprintln(w, `{"version": "1.1.0", "url": "http://example.com/update"}`)
}
}))
defer server.Close()
// Mock the doUpdateFunc to prevent actual updates
originalDoUpdate := DoUpdate
defer func() { DoUpdate = originalDoUpdate }()
DoUpdate = func(url string) error {
fmt.Printf("Update would be applied from: %s", url)
return nil
}
Version = "1.0.0"
err := CheckForUpdatesHTTP(server.URL)
if err != nil {
log.Fatalf("CheckForUpdatesHTTP failed: %v", err)
}
// Output:
// Newer version 1.1.0 found (current: 1.0.0). Applying update...
// Update would be applied from: http://example.com/update
}
func ExampleCheckOnlyHTTP() {
// Create a mock HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/latest.json" {
_, _ = fmt.Fprintln(w, `{"version": "1.1.0", "url": "http://example.com/update"}`)
}
}))
defer server.Close()
Version = "1.0.0"
err := CheckOnlyHTTP(server.URL)
if err != nil {
log.Fatalf("CheckOnlyHTTP failed: %v", err)
}
// Output: New release found: 1.1.0 (current version: 1.0.0)
}

View file

@ -1,5 +0,0 @@
package updater
// Generated by go:generate. DO NOT EDIT.
const PkgVersion = "1.2.3"