Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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`
Expand Down
58 changes: 58 additions & 0 deletions cli/internal/cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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":
Expand All @@ -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)
Expand Down Expand Up @@ -296,10 +352,12 @@ func printRootUsage(writer io.Writer) {

Usage:
monologue onboarding [flags]
monologue update
monologue notes <command> [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
Expand Down
45 changes: 45 additions & 0 deletions cli/internal/cmd/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
9 changes: 9 additions & 0 deletions cli/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions cli/internal/monologue/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions cli/internal/monologue/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
121 changes: 121 additions & 0 deletions cli/internal/update/check.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading