diff --git a/README.md b/README.md index e7343f4..abf7fc4 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ monologue notes list --limit 5 ```bash monologue version +monologue update monologue notes list --limit 10 monologue notes list --q "customer interview" monologue notes all --updated-after 2026-01-01T00:00:00Z @@ -130,14 +131,17 @@ Use `monologue --help` and `monologue notes --help` for the full command list. ## Update the CLI -If you installed with the shell or PowerShell installer, rerun the same install command to get the latest release. +Run the built-in updater: -If you installed with Go, rerun: - -```bash -go install github.com/EveryInc/monologue-toolkit/cli/cmd/monologue@latest +```console +$ monologue update +Updated Monologue CLI from 0.1.0 to v0.2.0. ``` +The CLI checks for a newer GitHub release at most once every 24 hours. When an +update is available, it prints a notice to stderr and leaves command output +unchanged for scripts and agents. + Check the installed version with: ```bash diff --git a/cli/README.md b/cli/README.md index 7664a5c..3a53f64 100644 --- a/cli/README.md +++ b/cli/README.md @@ -5,6 +5,7 @@ The `monologue` binary is a thin Go client for Monologue's public Notes API. ## Commands - `monologue version` +- `monologue update` - `monologue onboarding` - `monologue notes list` - `monologue notes all` @@ -36,6 +37,14 @@ yourself. Saved config lives in your user config directory under `monologue/config.json`. +The CLI checks GitHub Releases at most once every 24 hours. When a newer +version is available, it writes a notice to stderr without changing command +output. Update the installed binary with: + +```bash +monologue update +``` + Environment overrides remain available for trusted automation: - `MONOLOGUE_API_BASE_URL` diff --git a/cli/internal/cmd/app.go b/cli/internal/cmd/app.go index a397e21..d270c6a 100644 --- a/cli/internal/cmd/app.go +++ b/cli/internal/cmd/app.go @@ -7,13 +7,23 @@ import ( "fmt" "io" "strings" + "time" "github.com/EveryInc/monologue-toolkit/cli/internal/config" "github.com/EveryInc/monologue-toolkit/cli/internal/monologue" + cliupdate "github.com/EveryInc/monologue-toolkit/cli/internal/update" "github.com/EveryInc/monologue-toolkit/cli/internal/version" ) +var ( + checkForUpdate = cliupdate.CheckForUpdate + updateCLI = cliupdate.Update +) + func Run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { + if len(args) == 0 || args[0] != "update" { + maybeWarnAboutUpdate(stderr) + } if len(args) == 0 { printRootUsage(stderr) return 1 @@ -25,6 +35,8 @@ func Run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int return 0 case "onboarding": return runOnboarding(args[1:], stdin, stdout, stderr) + case "update": + return runUpdate(args[1:], stdout, stderr) case "notes": return runNotes(args[1:], stdin, stdout, stderr) case "-h", "--help", "help": @@ -37,6 +49,50 @@ func Run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int } } +func maybeWarnAboutUpdate(stderr io.Writer) { + currentVersion := version.Current() + cachePath, err := config.UpdateCheckPath() + if err != nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + latestVersion, outdated, err := checkForUpdate(ctx, currentVersion, cachePath) + if err != nil || !outdated { + return + } + fmt.Fprintf(stderr, "A newer Monologue CLI version is available (%s; you are using %s). Run `monologue update` to update.\n", latestVersion, currentVersion) +} + +func runUpdate(args []string, stdout io.Writer, stderr io.Writer) int { + fs := newFlagSet("monologue update", stderr) + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() != 0 { + fmt.Fprintln(stderr, "usage: monologue update") + return 1 + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + result, err := updateCLI(ctx, version.Current()) + if err != nil { + fmt.Fprintf(stderr, "update failed: %v\n", err) + return 1 + } + if !result.Updated { + fmt.Fprintf(stdout, "Monologue CLI is already up to date (%s).\n", result.CurrentVersion) + return 0 + } + if result.PendingRestart { + fmt.Fprintf(stdout, "Monologue CLI %s is downloaded and will finish updating when this command exits.\n", result.LatestVersion) + return 0 + } + fmt.Fprintf(stdout, "Updated Monologue CLI from %s to %s.\n", result.CurrentVersion, result.LatestVersion) + return 0 +} + func runNotes(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { if len(args) == 0 { printNotesUsage(stderr) @@ -296,10 +352,12 @@ func printRootUsage(writer io.Writer) { Usage: monologue onboarding [flags] + monologue update monologue notes [flags] Commands: version Show the installed CLI version + update Update the CLI to the latest release onboarding Save and verify Monologue API credentials notes onboarding Alias for onboarding notes list List one page of notes diff --git a/cli/internal/cmd/app_test.go b/cli/internal/cmd/app_test.go index 69e510b..e8e65ef 100644 --- a/cli/internal/cmd/app_test.go +++ b/cli/internal/cmd/app_test.go @@ -2,14 +2,59 @@ package cmd import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/EveryInc/monologue-toolkit/cli/internal/monologue" + cliupdate "github.com/EveryInc/monologue-toolkit/cli/internal/update" + "github.com/EveryInc/monologue-toolkit/cli/internal/version" ) +func TestRunWarnsOnStaleVersionWithoutChangingStdout(t *testing.T) { + originalVersion := version.Version + originalCheck := checkForUpdate + version.Version = "0.1.0" + checkForUpdate = func(context.Context, string, string) (string, bool, error) { + return "v0.2.0", true, nil + } + t.Cleanup(func() { + version.Version = originalVersion + checkForUpdate = originalCheck + }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + if exitCode := Run([]string{"version"}, bytes.NewReader(nil), &stdout, &stderr); exitCode != 0 { + t.Fatalf("Run returned %d", exitCode) + } + if got, want := stdout.String(), "monologue 0.1.0 (commit none, built unknown)\n"; got != want { + t.Fatalf("unexpected stdout: got %q, want %q", got, want) + } + if got, want := stderr.String(), "A newer Monologue CLI version is available (v0.2.0; you are using 0.1.0). Run `monologue update` to update.\n"; got != want { + t.Fatalf("unexpected stderr: got %q, want %q", got, want) + } +} + +func TestRunUpdateReportsSuccess(t *testing.T) { + originalUpdate := updateCLI + updateCLI = func(context.Context, string) (cliupdate.Result, error) { + return cliupdate.Result{CurrentVersion: "0.1.0", LatestVersion: "v0.2.0", Updated: true}, nil + } + t.Cleanup(func() { updateCLI = originalUpdate }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + if exitCode := Run([]string{"update"}, bytes.NewReader(nil), &stdout, &stderr); exitCode != 0 { + t.Fatalf("Run returned %d, stderr: %s", exitCode, stderr.String()) + } + if got, want := stdout.String(), "Updated Monologue CLI from 0.1.0 to v0.2.0.\n"; got != want { + t.Fatalf("unexpected stdout: got %q, want %q", got, want) + } +} + func TestRunNotesGetAcceptsFieldBeforeOrAfterNoteID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if got := request.URL.Path; got != "/v1/public-api/notes/note_123" { diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 7bc82f5..a59e7b7 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -98,6 +98,15 @@ func Path() (string, error) { return filepath.Join(configDir, "monologue", "config.json"), nil } +func UpdateCheckPath() (string, error) { + configPath, err := Path() + if err != nil { + return "", err + } + + return filepath.Join(filepath.Dir(configPath), "update-check.json"), nil +} + func read(path string) (StoredConfig, error) { payload, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { diff --git a/cli/internal/monologue/client.go b/cli/internal/monologue/client.go index bd36955..6716d63 100644 --- a/cli/internal/monologue/client.go +++ b/cli/internal/monologue/client.go @@ -9,9 +9,9 @@ import ( "net/url" "strings" "time" -) -const userAgent = "monologue-toolkit/0.1" + "github.com/EveryInc/monologue-toolkit/cli/internal/version" +) type Client struct { baseURL string @@ -136,7 +136,7 @@ func (c *Client) doJSON(ctx context.Context, method string, endpoint string, out request.Header.Set("Accept", "application/json") request.Header.Set("Authorization", "Bearer "+c.token) - request.Header.Set("User-Agent", userAgent) + request.Header.Set("User-Agent", "monologue-toolkit/"+version.Current()) response, err := c.httpClient.Do(request) if err != nil { diff --git a/cli/internal/monologue/client_test.go b/cli/internal/monologue/client_test.go index 1765470..e8a8d20 100644 --- a/cli/internal/monologue/client_test.go +++ b/cli/internal/monologue/client_test.go @@ -17,6 +17,9 @@ func TestListNotesSendsAuthAndFilters(t *testing.T) { if got := request.Header.Get("Authorization"); got != "Bearer mono_pat_test" { t.Fatalf("unexpected auth header: %q", got) } + if got := request.Header.Get("User-Agent"); got != "monologue-toolkit/dev" { + t.Fatalf("unexpected user agent: %q", got) + } if got := request.URL.Path; got != "/v1/public-api/notes" { t.Fatalf("unexpected path: %q", got) } diff --git a/cli/internal/update/check.go b/cli/internal/update/check.go new file mode 100644 index 0000000..2bed688 --- /dev/null +++ b/cli/internal/update/check.go @@ -0,0 +1,121 @@ +package update + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "runtime" + "time" +) + +const checkInterval = 24 * time.Hour + +type checkCache struct { + CheckedAt time.Time `json:"checked_at"` + LatestVersion string `json:"latest_version"` +} + +func CheckForUpdate(ctx context.Context, currentVersion string, cachePath string) (string, bool, error) { + if _, err := parseVersion(currentVersion); err != nil { + return "", false, nil + } + + httpClient := &http.Client{Timeout: 2 * time.Second} + return checkForUpdate(ctx, currentVersion, cachePath, time.Now(), func(ctx context.Context) (release, error) { + return fetchLatestRelease(ctx, httpClient, latestReleaseURL, currentVersion) + }) +} + +func checkForUpdate( + ctx context.Context, + currentVersion string, + cachePath string, + now time.Time, + fetch func(context.Context) (release, error), +) (string, bool, error) { + cached, cacheErr := readCheckCache(cachePath) + if cacheErr == nil && now.Sub(cached.CheckedAt) >= 0 && now.Sub(cached.CheckedAt) < checkInterval { + if cached.LatestVersion == "" { + return "", false, nil + } + newer, err := isNewer(cached.LatestVersion, currentVersion) + return cached.LatestVersion, newer, err + } + + latest, err := fetch(ctx) + if err != nil { + failedCheck := checkCache{CheckedAt: now.UTC()} + if cacheErr == nil { + failedCheck.LatestVersion = cached.LatestVersion + _ = writeCheckCache(cachePath, failedCheck) + if cached.LatestVersion == "" { + return "", false, nil + } + newer, compareErr := isNewer(cached.LatestVersion, currentVersion) + return cached.LatestVersion, newer, compareErr + } + _ = writeCheckCache(cachePath, failedCheck) + return "", false, nil + } + + _ = writeCheckCache(cachePath, checkCache{CheckedAt: now.UTC(), LatestVersion: latest.TagName}) + newer, err := isNewer(latest.TagName, currentVersion) + return latest.TagName, newer, err +} + +func readCheckCache(path string) (checkCache, error) { + payload, err := os.ReadFile(path) + if err != nil { + return checkCache{}, err + } + + var cached checkCache + if err := json.Unmarshal(payload, &cached); err != nil { + return checkCache{}, err + } + if cached.CheckedAt.IsZero() { + return checkCache{}, errors.New("incomplete update check cache") + } + return cached, nil +} + +func writeCheckCache(path string, cached checkCache) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + payload, err := json.MarshalIndent(cached, "", " ") + if err != nil { + return err + } + payload = append(payload, '\n') + + tempFile, err := os.CreateTemp(filepath.Dir(path), ".update-check-*") + if err != nil { + return err + } + tempPath := tempFile.Name() + defer os.Remove(tempPath) + + if err := tempFile.Chmod(0o600); err != nil { + tempFile.Close() + return err + } + if _, err := tempFile.Write(payload); err != nil { + tempFile.Close() + return err + } + if err := tempFile.Close(); err != nil { + return err + } + err = os.Rename(tempPath, path) + if err == nil || runtime.GOOS != "windows" { + return err + } + if removeErr := os.Remove(path); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + return err + } + return os.Rename(tempPath, path) +} diff --git a/cli/internal/update/check_test.go b/cli/internal/update/check_test.go new file mode 100644 index 0000000..17cd92e --- /dev/null +++ b/cli/internal/update/check_test.go @@ -0,0 +1,69 @@ +package update + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" +) + +func TestCheckForUpdateCachesLatestRelease(t *testing.T) { + t.Parallel() + cachePath := filepath.Join(t.TempDir(), "nested", "update-check.json") + now := time.Date(2026, time.August, 3, 0, 0, 0, 0, time.UTC) + fetchCount := 0 + fetch := func(context.Context) (release, error) { + fetchCount++ + return release{TagName: "v0.2.0"}, nil + } + + latest, outdated, err := checkForUpdate(context.Background(), "0.1.0", cachePath, now, fetch) + if err != nil || latest != "v0.2.0" || !outdated { + t.Fatalf("unexpected first check: latest=%q outdated=%v err=%v", latest, outdated, err) + } + latest, outdated, err = checkForUpdate(context.Background(), "0.1.0", cachePath, now.Add(time.Hour), fetch) + if err != nil || latest != "v0.2.0" || !outdated { + t.Fatalf("unexpected cached check: latest=%q outdated=%v err=%v", latest, outdated, err) + } + if fetchCount != 1 { + t.Fatalf("fetch count = %d, want 1", fetchCount) + } +} + +func TestCheckForUpdateUsesStaleCacheWhenRefreshFails(t *testing.T) { + t.Parallel() + cachePath := filepath.Join(t.TempDir(), "update-check.json") + now := time.Date(2026, time.August, 3, 0, 0, 0, 0, time.UTC) + if err := writeCheckCache(cachePath, checkCache{CheckedAt: now.Add(-48 * time.Hour), LatestVersion: "v0.2.0"}); err != nil { + t.Fatal(err) + } + + latest, outdated, err := checkForUpdate(context.Background(), "0.1.0", cachePath, now, func(context.Context) (release, error) { + return release{}, errors.New("offline") + }) + if err != nil || latest != "v0.2.0" || !outdated { + t.Fatalf("unexpected stale-cache result: latest=%q outdated=%v err=%v", latest, outdated, err) + } +} + +func TestCheckForUpdateThrottlesFailedRefreshWithoutExistingCache(t *testing.T) { + t.Parallel() + cachePath := filepath.Join(t.TempDir(), "update-check.json") + now := time.Date(2026, time.August, 3, 0, 0, 0, 0, time.UTC) + fetchCount := 0 + fetch := func(context.Context) (release, error) { + fetchCount++ + return release{}, errors.New("offline") + } + + for _, checkedAt := range []time.Time{now, now.Add(time.Hour)} { + latest, outdated, err := checkForUpdate(context.Background(), "0.1.0", cachePath, checkedAt, fetch) + if err != nil || latest != "" || outdated { + t.Fatalf("unexpected failed-check result: latest=%q outdated=%v err=%v", latest, outdated, err) + } + } + if fetchCount != 1 { + t.Fatalf("fetch count = %d, want 1", fetchCount) + } +} diff --git a/cli/internal/update/install_unix.go b/cli/internal/update/install_unix.go new file mode 100644 index 0000000..2398264 --- /dev/null +++ b/cli/internal/update/install_unix.go @@ -0,0 +1,55 @@ +//go:build !windows + +package update + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +func installBinary(source string, target string) (bool, error) { + info, err := os.Stat(target) + if err != nil { + return false, err + } + + tempFile, err := os.CreateTemp(filepath.Dir(target), ".monologue-update-*") + if err != nil { + return false, err + } + tempPath := tempFile.Name() + defer os.Remove(tempPath) + + sourceFile, err := os.Open(source) + if err != nil { + tempFile.Close() + return false, err + } + _, copyErr := io.Copy(tempFile, sourceFile) + closeSourceErr := sourceFile.Close() + if copyErr != nil { + tempFile.Close() + return false, copyErr + } + if closeSourceErr != nil { + tempFile.Close() + return false, closeSourceErr + } + if err := tempFile.Chmod(info.Mode().Perm() | 0o111); err != nil { + tempFile.Close() + return false, err + } + if err := tempFile.Sync(); err != nil { + tempFile.Close() + return false, err + } + if err := tempFile.Close(); err != nil { + return false, err + } + if err := os.Rename(tempPath, target); err != nil { + return false, fmt.Errorf("install updated executable: %w", err) + } + return false, nil +} diff --git a/cli/internal/update/install_unix_test.go b/cli/internal/update/install_unix_test.go new file mode 100644 index 0000000..fcaa2e4 --- /dev/null +++ b/cli/internal/update/install_unix_test.go @@ -0,0 +1,37 @@ +//go:build !windows + +package update + +import ( + "os" + "path/filepath" + "testing" +) + +func TestInstallBinaryAtomicallyReplacesTarget(t *testing.T) { + t.Parallel() + directory := t.TempDir() + source := filepath.Join(directory, "source") + target := filepath.Join(directory, "monologue") + if err := os.WriteFile(source, []byte("new"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + + pending, err := installBinary(source, target) + if err != nil { + t.Fatal(err) + } + if pending { + t.Fatal("unix install should not be pending") + } + payload, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(payload) != "new" { + t.Fatalf("target payload = %q, want new", payload) + } +} diff --git a/cli/internal/update/install_windows.go b/cli/internal/update/install_windows.go new file mode 100644 index 0000000..2a29a9f --- /dev/null +++ b/cli/internal/update/install_windows.go @@ -0,0 +1,102 @@ +//go:build windows + +package update + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" +) + +const ( + createNoWindow = 0x08000000 + detachedProcess = 0x00000008 + updateHelperFileMode = 0o600 + updatedBinaryFileMode = 0o755 +) + +func installBinary(source string, target string) (bool, error) { + stagedFile, err := os.CreateTemp(filepath.Dir(target), ".monologue-update-*.exe") + if err != nil { + return false, err + } + stagedPath := stagedFile.Name() + cleanupStaged := true + defer func() { + if cleanupStaged { + os.Remove(stagedPath) + } + }() + + sourceFile, err := os.Open(source) + if err != nil { + stagedFile.Close() + return false, err + } + _, copyErr := io.Copy(stagedFile, sourceFile) + closeSourceErr := sourceFile.Close() + if copyErr != nil { + stagedFile.Close() + return false, copyErr + } + if closeSourceErr != nil { + stagedFile.Close() + return false, closeSourceErr + } + if err := stagedFile.Chmod(updatedBinaryFileMode); err != nil { + stagedFile.Close() + return false, err + } + if err := stagedFile.Close(); err != nil { + return false, err + } + + helperFile, err := os.CreateTemp("", "monologue-update-*.ps1") + if err != nil { + return false, err + } + helperPath := helperFile.Name() + cleanupHelper := true + defer func() { + if cleanupHelper { + os.Remove(helperPath) + } + }() + + script := fmt.Sprintf( + "$ErrorActionPreference = 'Stop'\nGet-Process -Id %d -ErrorAction SilentlyContinue | Wait-Process\nMove-Item -LiteralPath '%s' -Destination '%s' -Force\nRemove-Item -LiteralPath '%s' -Force\n", + os.Getpid(), powershellQuote(stagedPath), powershellQuote(target), powershellQuote(helperPath), + ) + if err := helperFile.Chmod(updateHelperFileMode); err != nil { + helperFile.Close() + return false, err + } + if _, err := helperFile.WriteString(script); err != nil { + helperFile.Close() + return false, err + } + if err := helperFile.Close(); err != nil { + return false, err + } + + command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", helperPath) + command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow | detachedProcess, HideWindow: true} + if err := command.Start(); err != nil { + return false, fmt.Errorf("start update helper: %w", err) + } + if err := command.Process.Release(); err != nil { + return false, fmt.Errorf("detach update helper: %w", err) + } + + cleanupStaged = false + cleanupHelper = false + return true, nil +} + +func powershellQuote(value string) string { + return strings.ReplaceAll(value, "'", "''") +} diff --git a/cli/internal/update/update.go b/cli/internal/update/update.go new file mode 100644 index 0000000..9bec197 --- /dev/null +++ b/cli/internal/update/update.go @@ -0,0 +1,298 @@ +package update + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + latestReleaseURL = "https://api.github.com/repos/EveryInc/monologue-toolkit/releases/latest" + maxDownloadSize = 100 << 20 +) + +type release struct { + TagName string `json:"tag_name"` + Assets []asset `json:"assets"` +} + +type asset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` +} + +type Result struct { + CurrentVersion string + LatestVersion string + ExecutablePath string + Updated bool + PendingRestart bool +} + +func Update(ctx context.Context, currentVersion string) (Result, error) { + if _, err := parseVersion(currentVersion); err != nil { + return Result{}, fmt.Errorf("cannot self-update unversioned build %q: install a released CLI first", currentVersion) + } + + httpClient := &http.Client{Timeout: 2 * time.Minute} + latest, err := fetchLatestRelease(ctx, httpClient, latestReleaseURL, currentVersion) + if err != nil { + return Result{}, err + } + + newer, err := isNewer(latest.TagName, currentVersion) + if err != nil { + return Result{}, err + } + if !newer { + return Result{CurrentVersion: currentVersion, LatestVersion: latest.TagName}, nil + } + + archiveName, err := archiveName(runtime.GOOS, runtime.GOARCH) + if err != nil { + return Result{}, err + } + archiveAsset, err := findAsset(latest, archiveName) + if err != nil { + return Result{}, err + } + checksumsAsset, err := findAsset(latest, "checksums.txt") + if err != nil { + return Result{}, err + } + + archivePayload, err := download(ctx, httpClient, archiveAsset.URL, currentVersion) + if err != nil { + return Result{}, fmt.Errorf("download %s: %w", archiveName, err) + } + checksumsPayload, err := download(ctx, httpClient, checksumsAsset.URL, currentVersion) + if err != nil { + return Result{}, fmt.Errorf("download checksums.txt: %w", err) + } + if err := verifyChecksum(archiveName, archivePayload, checksumsPayload); err != nil { + return Result{}, err + } + + tempDir, err := os.MkdirTemp("", "monologue-update-*") + if err != nil { + return Result{}, err + } + defer os.RemoveAll(tempDir) + + binaryPath := filepath.Join(tempDir, binaryName(runtime.GOOS)) + if err := extractBinary(archiveName, archivePayload, binaryPath); err != nil { + return Result{}, err + } + executablePath, err := os.Executable() + if err != nil { + return Result{}, fmt.Errorf("locate current executable: %w", err) + } + executablePath, err = filepath.EvalSymlinks(executablePath) + if err != nil { + return Result{}, fmt.Errorf("resolve current executable: %w", err) + } + + pendingRestart, err := installBinary(binaryPath, executablePath) + if err != nil { + return Result{}, fmt.Errorf("replace %s: %w", executablePath, err) + } + return Result{ + CurrentVersion: currentVersion, + LatestVersion: latest.TagName, + ExecutablePath: executablePath, + Updated: true, + PendingRestart: pendingRestart, + }, nil +} + +func fetchLatestRelease(ctx context.Context, httpClient *http.Client, endpoint string, currentVersion string) (release, error) { + payload, err := download(ctx, httpClient, endpoint, currentVersion) + if err != nil { + return release{}, fmt.Errorf("check latest release: %w", err) + } + + var latest release + if err := json.Unmarshal(payload, &latest); err != nil { + return release{}, fmt.Errorf("decode latest release: %w", err) + } + if latest.TagName == "" { + return release{}, errors.New("latest release did not include a version") + } + if _, err := parseVersion(latest.TagName); err != nil { + return release{}, fmt.Errorf("latest release version: %w", err) + } + return latest, nil +} + +func download(ctx context.Context, httpClient *http.Client, url string, currentVersion string) ([]byte, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("User-Agent", "monologue-toolkit/"+currentVersion) + + response, err := httpClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("HTTP %d", response.StatusCode) + } + + payload, err := io.ReadAll(io.LimitReader(response.Body, maxDownloadSize+1)) + if err != nil { + return nil, err + } + if len(payload) > maxDownloadSize { + return nil, errors.New("download exceeded 100 MiB limit") + } + return payload, nil +} + +func archiveName(goos string, goarch string) (string, error) { + if goarch != "amd64" && goarch != "arm64" { + return "", fmt.Errorf("unsupported architecture: %s", goarch) + } + switch goos { + case "darwin", "linux": + return fmt.Sprintf("monologue_%s_%s.tar.gz", goos, goarch), nil + case "windows": + return fmt.Sprintf("monologue_windows_%s.zip", goarch), nil + default: + return "", fmt.Errorf("unsupported operating system: %s", goos) + } +} + +func findAsset(latest release, name string) (asset, error) { + for _, candidate := range latest.Assets { + if candidate.Name == name && candidate.URL != "" { + return candidate, nil + } + } + return asset{}, fmt.Errorf("release %s does not include %s", latest.TagName, name) +} + +func verifyChecksum(name string, payload []byte, checksums []byte) error { + expected := "" + for _, line := range strings.Split(string(checksums), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == name { + expected = strings.ToLower(fields[0]) + break + } + } + if expected == "" { + return fmt.Errorf("checksums.txt does not include %s", name) + } + if _, err := hex.DecodeString(expected); err != nil || len(expected) != sha256.Size*2 { + return fmt.Errorf("invalid SHA-256 checksum for %s", name) + } + actual := sha256.Sum256(payload) + if hex.EncodeToString(actual[:]) != expected { + return fmt.Errorf("checksum verification failed for %s", name) + } + return nil +} + +func extractBinary(archiveName string, payload []byte, destination string) error { + if strings.HasSuffix(archiveName, ".zip") { + return extractZipBinary(payload, destination) + } + return extractTarBinary(payload, destination) +} + +func extractTarBinary(payload []byte, destination string) error { + gzipReader, err := gzip.NewReader(bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("open release archive: %w", err) + } + defer gzipReader.Close() + + tarReader := tar.NewReader(gzipReader) + for { + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("read release archive: %w", err) + } + if safeArchiveName(header.Name) != "monologue" || header.Typeflag != tar.TypeReg { + continue + } + return writeExtractedBinary(destination, tarReader) + } + return errors.New("release archive does not include monologue") +} + +func extractZipBinary(payload []byte, destination string) error { + zipReader, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload))) + if err != nil { + return fmt.Errorf("open release archive: %w", err) + } + for _, file := range zipReader.File { + if safeArchiveName(file.Name) != "monologue.exe" || file.FileInfo().IsDir() { + continue + } + reader, err := file.Open() + if err != nil { + return fmt.Errorf("open monologue.exe in release archive: %w", err) + } + writeErr := writeExtractedBinary(destination, reader) + closeErr := reader.Close() + if writeErr != nil { + return writeErr + } + return closeErr + } + return errors.New("release archive does not include monologue.exe") +} + +func safeArchiveName(name string) string { + cleaned := path.Clean(strings.ReplaceAll(name, "\\", "/")) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || path.IsAbs(cleaned) { + return "" + } + return path.Base(cleaned) +} + +func writeExtractedBinary(destination string, reader io.Reader) error { + file, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755) + if err != nil { + return err + } + written, err := io.Copy(file, io.LimitReader(reader, maxDownloadSize+1)) + if err != nil { + file.Close() + return err + } + if written > maxDownloadSize { + file.Close() + return errors.New("extracted binary exceeded 100 MiB limit") + } + return file.Close() +} + +func binaryName(goos string) string { + if goos == "windows" { + return "monologue.exe" + } + return "monologue" +} diff --git a/cli/internal/update/update_test.go b/cli/internal/update/update_test.go new file mode 100644 index 0000000..d7ec82f --- /dev/null +++ b/cli/internal/update/update_test.go @@ -0,0 +1,131 @@ +package update + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestFetchLatestRelease(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if got := request.Header.Get("User-Agent"); got != "monologue-toolkit/0.1.0" { + t.Fatalf("unexpected user agent: %q", got) + } + _ = json.NewEncoder(writer).Encode(release{ + TagName: "v0.2.0", + Assets: []asset{{Name: "checksums.txt", URL: "https://example.com/checksums.txt"}}, + }) + })) + defer server.Close() + + latest, err := fetchLatestRelease(context.Background(), server.Client(), server.URL, "0.1.0") + if err != nil { + t.Fatal(err) + } + if latest.TagName != "v0.2.0" || len(latest.Assets) != 1 { + t.Fatalf("unexpected release: %#v", latest) + } +} + +func TestArchiveName(t *testing.T) { + t.Parallel() + tests := []struct { + goos string + goarch string + want string + }{ + {goos: "darwin", goarch: "arm64", want: "monologue_darwin_arm64.tar.gz"}, + {goos: "linux", goarch: "amd64", want: "monologue_linux_amd64.tar.gz"}, + {goos: "windows", goarch: "arm64", want: "monologue_windows_arm64.zip"}, + } + for _, test := range tests { + got, err := archiveName(test.goos, test.goarch) + if err != nil || got != test.want { + t.Errorf("archiveName(%q, %q) = %q, %v; want %q", test.goos, test.goarch, got, err, test.want) + } + } +} + +func TestVerifyChecksum(t *testing.T) { + t.Parallel() + payload := []byte("release archive") + digest := sha256.Sum256(payload) + checksums := []byte(fmt.Sprintf("%x monologue_darwin_arm64.tar.gz\n", digest)) + if err := verifyChecksum("monologue_darwin_arm64.tar.gz", payload, checksums); err != nil { + t.Fatal(err) + } + if err := verifyChecksum("monologue_darwin_arm64.tar.gz", []byte("tampered"), checksums); err == nil { + t.Fatal("expected checksum mismatch") + } +} + +func TestExtractTarBinary(t *testing.T) { + t.Parallel() + var archive bytes.Buffer + gzipWriter := gzip.NewWriter(&archive) + tarWriter := tar.NewWriter(gzipWriter) + payload := []byte("new binary") + if err := tarWriter.WriteHeader(&tar.Header{Name: "monologue", Mode: 0o755, Size: int64(len(payload))}); err != nil { + t.Fatal(err) + } + if _, err := tarWriter.Write(payload); err != nil { + t.Fatal(err) + } + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } + + destination := filepath.Join(t.TempDir(), "monologue") + if err := extractTarBinary(archive.Bytes(), destination); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if string(got) != string(payload) { + t.Fatalf("extracted payload = %q, want %q", got, payload) + } +} + +func TestExtractZipBinary(t *testing.T) { + t.Parallel() + var archive bytes.Buffer + zipWriter := zip.NewWriter(&archive) + file, err := zipWriter.Create("monologue.exe") + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte("windows binary")); err != nil { + t.Fatal(err) + } + if err := zipWriter.Close(); err != nil { + t.Fatal(err) + } + + destination := filepath.Join(t.TempDir(), "monologue.exe") + if err := extractZipBinary(archive.Bytes(), destination); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if string(got) != "windows binary" { + t.Fatalf("extracted payload = %q", got) + } +} diff --git a/cli/internal/update/version.go b/cli/internal/update/version.go new file mode 100644 index 0000000..b9a8a69 --- /dev/null +++ b/cli/internal/update/version.go @@ -0,0 +1,118 @@ +package update + +import ( + "fmt" + "strconv" + "strings" +) + +type semanticVersion struct { + major int + minor int + patch int + prerelease []string +} + +func parseVersion(value string) (semanticVersion, error) { + value = strings.TrimSpace(strings.TrimPrefix(value, "v")) + value = strings.SplitN(value, "+", 2)[0] + parts := strings.SplitN(value, "-", 2) + core := strings.Split(parts[0], ".") + if len(core) != 3 { + return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value) + } + + numbers := make([]int, 3) + for index, part := range core { + number, err := strconv.Atoi(part) + if err != nil || number < 0 { + return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value) + } + numbers[index] = number + } + + parsed := semanticVersion{major: numbers[0], minor: numbers[1], patch: numbers[2]} + if len(parts) == 2 { + if parts[1] == "" { + return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value) + } + parsed.prerelease = strings.Split(parts[1], ".") + } + return parsed, nil +} + +func isNewer(latest string, current string) (bool, error) { + latestVersion, err := parseVersion(latest) + if err != nil { + return false, err + } + currentVersion, err := parseVersion(current) + if err != nil { + return false, err + } + + latestCore := []int{latestVersion.major, latestVersion.minor, latestVersion.patch} + currentCore := []int{currentVersion.major, currentVersion.minor, currentVersion.patch} + for index := range latestCore { + if latestCore[index] != currentCore[index] { + return latestCore[index] > currentCore[index], nil + } + } + + return comparePrerelease(latestVersion.prerelease, currentVersion.prerelease) > 0, nil +} + +func comparePrerelease(left []string, right []string) int { + if len(left) == 0 && len(right) == 0 { + return 0 + } + if len(left) == 0 { + return 1 + } + if len(right) == 0 { + return -1 + } + + length := len(left) + if len(right) < length { + length = len(right) + } + for index := 0; index < length; index++ { + leftNumber, leftNumeric := numericIdentifier(left[index]) + rightNumber, rightNumeric := numericIdentifier(right[index]) + switch { + case leftNumeric && rightNumeric && leftNumber != rightNumber: + if leftNumber > rightNumber { + return 1 + } + return -1 + case leftNumeric != rightNumeric: + if leftNumeric { + return -1 + } + return 1 + case left[index] != right[index]: + if left[index] > right[index] { + return 1 + } + return -1 + } + } + + switch { + case len(left) > len(right): + return 1 + case len(left) < len(right): + return -1 + default: + return 0 + } +} + +func numericIdentifier(value string) (int, bool) { + if value == "" { + return 0, false + } + number, err := strconv.Atoi(value) + return number, err == nil +} diff --git a/cli/internal/update/version_test.go b/cli/internal/update/version_test.go new file mode 100644 index 0000000..45d77b8 --- /dev/null +++ b/cli/internal/update/version_test.go @@ -0,0 +1,35 @@ +package update + +import "testing" + +func TestUpdateRejectsUnversionedBuildBeforeNetworkAccess(t *testing.T) { + t.Parallel() + if _, err := Update(t.Context(), "dev"); err == nil { + t.Fatal("expected unversioned build error") + } +} + +func TestIsNewer(t *testing.T) { + t.Parallel() + tests := []struct { + latest string + current string + want bool + }{ + {latest: "v0.2.0", current: "0.1.0", want: true}, + {latest: "v0.2.0", current: "0.2.0", want: false}, + {latest: "v0.1.9", current: "0.2.0", want: false}, + {latest: "v1.0.0", current: "0.9.9", want: true}, + {latest: "v1.0.0", current: "1.0.0-rc.1", want: true}, + {latest: "v1.0.0-rc.2", current: "1.0.0-rc.1", want: true}, + } + for _, test := range tests { + got, err := isNewer(test.latest, test.current) + if err != nil { + t.Fatalf("isNewer(%q, %q): %v", test.latest, test.current, err) + } + if got != test.want { + t.Errorf("isNewer(%q, %q) = %v, want %v", test.latest, test.current, got, test.want) + } + } +} diff --git a/cli/internal/version/version.go b/cli/internal/version/version.go index 0bb9e42..f95c82c 100644 --- a/cli/internal/version/version.go +++ b/cli/internal/version/version.go @@ -1,6 +1,9 @@ package version -import "fmt" +import ( + "fmt" + "runtime/debug" +) var ( Version = "dev" @@ -9,5 +12,17 @@ var ( ) func String() string { - return fmt.Sprintf("monologue %s (commit %s, built %s)", Version, Commit, Date) + return fmt.Sprintf("monologue %s (commit %s, built %s)", Current(), Commit, Date) +} + +func Current() string { + if Version != "dev" { + return Version + } + + buildInfo, ok := debug.ReadBuildInfo() + if ok && buildInfo.Main.Version != "" && buildInfo.Main.Version != "(devel)" { + return buildInfo.Main.Version + } + return Version } diff --git a/cli/internal/version/version_test.go b/cli/internal/version/version_test.go new file mode 100644 index 0000000..0c85d2d --- /dev/null +++ b/cli/internal/version/version_test.go @@ -0,0 +1,13 @@ +package version + +import "testing" + +func TestCurrentPrefersInjectedReleaseVersion(t *testing.T) { + originalVersion := Version + Version = "0.2.0" + t.Cleanup(func() { Version = originalVersion }) + + if got, want := Current(), "0.2.0"; got != want { + t.Fatalf("Current() = %q, want %q", got, want) + } +} diff --git a/install.ps1 b/install.ps1 index aa3eda7..c3b7bae 100644 --- a/install.ps1 +++ b/install.ps1 @@ -61,8 +61,7 @@ try { Write-Host " 1. Add $InstallDir to your PATH if needed" Write-Host " 2. Run: monologue onboarding" Write-Host "" - Write-Host "To update later, rerun this installer." + Write-Host "To update later, run: monologue update" } finally { Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue } - diff --git a/install.sh b/install.sh index 346238d..29a3824 100644 --- a/install.sh +++ b/install.sh @@ -130,5 +130,4 @@ echo "Next steps:" echo " 1. Make sure $INSTALL_DIR is on your PATH" echo " 2. Run: $BINARY_NAME onboarding" echo -echo "To update later, rerun this installer." - +echo "To update later, run: $BINARY_NAME update"