Skip to content
Merged
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
69 changes: 69 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,55 @@ Both are exported from `version.ts`. Migration tracking — including the
downgrade-rejection guard in the runners — uses `SERVER_VERSION`, so the
DB's recorded version advances only when the server is released.

### Compatibility bounds

Because the client and server release independently, each side declares
the oldest counterpart it tolerates:

| Constant | Source of truth | Authority on |
|---|---|---|
| `MIN_CLIENT_VERSION` | `version.ts` | Oldest CLI/client this server will accept. |
| `MIN_SERVER_VERSION` | `version.ts` | Oldest server this CLI/client will talk to. |

Enforcement points:

- **Per-request**: the client sends `X-Client-Version: <CLIENT_VERSION>` on
every RPC (`packages/client/transport.ts`). The server's
`checkClientVersion` middleware (`packages/server/middleware/client-version.ts`)
rejects too-old clients before dispatch with a JSON-RPC envelope whose
`data.code` is `CLIENT_VERSION_INCOMPATIBLE` and HTTP status `426 Upgrade Required`.
Missing or malformed headers are allowed through (lenient mode) so this
feature can be rolled out without breaking older clients.
- **Probe**: `GET /api/v1/version` is unauthenticated and returns
`{ serverVersion, minClientVersion, client?: { version, compatible } }`.
The CLI's `checkServerVersion` helper (`packages/client/version.ts`) calls
this on `me login` and `me version`, throwing typed `RpcError`s for both
`CLIENT_VERSION_INCOMPATIBLE` and `SERVER_VERSION_INCOMPATIBLE`.

### Bumping `MIN_CLIENT_VERSION` / `MIN_SERVER_VERSION`

Bump these only when you intentionally break compatibility — i.e. you are
shipping a server that no longer supports an older client wire format, or a
client that requires a server feature added in a specific release.

- Bump `MIN_CLIENT_VERSION` when **the server** drops support for an older
protocol shape. After the bump, clients below the new minimum will see
`CLIENT_VERSION_INCOMPATIBLE` instead of confusing 4xx/5xx errors.
The `bun run release:server` script prompts for this; you can also edit
`version.ts` directly.
- Bump `MIN_SERVER_VERSION` when **the client** depends on a feature that
only newer servers expose. After the bump, the client refuses to talk to
older servers with `SERVER_VERSION_INCOMPATIBLE`.
The `bun run release:client` script prompts for this.

Rule of thumb: pin the new minimum at the *current* counterpart version
(e.g. when releasing server `0.2.0` that drops support for protocol shapes
older than client `0.2.0`, set `MIN_CLIENT_VERSION = "0.2.0"`).

If you forget to bump, nothing breaks per se — older counterparts will fail
later with less helpful errors. Bumping is the difference between a clear
upgrade prompt and "method not found" / "invalid params" mid-command.

### Two scripts

**`bun run release:client`** — bumps the version in:
Expand All @@ -226,6 +275,10 @@ DB's recorded version advances only when the server is released.
- `packages/client/package.json`
- `packages/protocol/package.json`

Then prompts whether to bump `MIN_SERVER_VERSION` in `version.ts` (leave
blank to keep the current value — see [Bumping
`MIN_CLIENT_VERSION` / `MIN_SERVER_VERSION`](#bumping-min_client_version--min_server_version)).

Commits, creates an annotated tag `v<version>`, and pushes.

The `v*` tag triggers `.github/workflows/release.yml`, which:
Expand All @@ -245,6 +298,9 @@ in lockstep:
- `packages/server/package.json` (canonical source)
- `packages/worker/package.json`

Then prompts whether to bump `MIN_CLIENT_VERSION` in `version.ts` (leave
blank to keep the current value).

Commits, creates an annotated tag `server/v<version>`, and pushes.

The `server/v*` tag triggers `.github/workflows/deploy-prod.yaml`, which
Expand Down Expand Up @@ -273,6 +329,19 @@ Both scripts require:
- Tag doesn't already exist.
- Explicit `y` confirmation at the prompt.

After confirmation, both scripts also prompt for an optional
`MIN_*_VERSION` bump in `version.ts`:

```
? Bump MIN_SERVER_VERSION? (current: 0.1.17, leave blank to keep):
```

Press Enter to skip (most releases). Type a semver to bump it (only when
you're intentionally breaking compatibility with older counterparts — see
[Bumping `MIN_CLIENT_VERSION` / `MIN_SERVER_VERSION`](#bumping-min_client_version--min_server_version)).
The bumped `version.ts` is included in the same commit as the package
version bumps.

### Typical workflow

Because the client and server release independently, the typical sequence
Expand Down
76 changes: 76 additions & 0 deletions docs/cli/me-version.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# me version

Show CLI and server versions and check compatibility.

## Usage

```
me version [--local]
```

## Description

Prints the running CLI version, then probes the configured server's
`GET /api/v1/version` endpoint and prints:

- the server's version,
- the oldest client version the server accepts (`minClientVersion`),
- a confirmation that the two are compatible.

If the server is too old for this CLI, or the CLI is too old for the
server, the command prints an explanatory error and exits with status
`1`. This makes `me version` suitable as a CI gate before running other
`me` commands.

The `me login` command runs the same compatibility check internally
before starting the OAuth flow, so users see a clean upgrade prompt
instead of obscure errors mid-login.

## Options

| Option | Description |
|--------|-------------|
| `--local` | Skip the server probe; print only the local CLI version. |

## Global Options

| Option | Description |
|--------|-------------|
| `--server <url>` | Server URL to probe (overrides `ME_SERVER` env and stored default). |
| `--json` | Output as JSON. |
| `--yaml` | Output as YAML. |

## Examples

Check the configured server:

```
me version
```

Check a specific server:

```
me version --server https://api.memory.build
```

Local-only output (e.g. on an air-gapped machine):

```
me version --local
```

JSON output for scripts and CI:

```
me version --json
```

## Notes

- `me --version` (with two dashes) prints only the CLI version, like
most CLIs. `me version` (no dashes) is the richer diagnostic command
that also probes the server.
- If the server reports the CLI is too old, follow the upgrade
instructions in the error message. If the server is too old for this
CLI, contact whoever runs the server.
6 changes: 6 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ me login

This starts an OAuth flow via GitHub -- authorize in your browser and the CLI stores your session.

If your CLI is older than the server (or vice versa), `me login` will tell you and bail out before sending you to the browser. You can run the same check explicitly:

```bash
me version
```


## Store your first memory

Expand Down
3 changes: 2 additions & 1 deletion packages/cli/claude/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
* construction are testable in isolation. The `captureHookEvent` entry
* point handles memory creation via EngineClient.
*/
import { createClient, type EngineClient } from "@memory.build/client";

import { CLIENT_VERSION } from "../../../version";
import { createClient, type EngineClient } from "../client.ts";

// =============================================================================
// Hook config (derived at runtime from CLAUDE_PLUGIN_OPTION_* env vars)
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* CLI-local wrapper around `@memory.build/client`.
*
* Auto-injects `CLIENT_VERSION` into every client/transport so the server
* can run its `X-Client-Version` compatibility check. Otherwise re-exports
* the upstream API verbatim — types, errors, helpers — so command files
* import everything from one place.
*/
import {
type AccountsClient,
type AccountsClientOptions,
type AuthClient,
type AuthClientOptions,
createAccountsClient as baseCreateAccountsClient,
createAuthClient as baseCreateAuthClient,
createClient as baseCreateClient,
type ClientOptions,
type EngineClient,
} from "@memory.build/client";
import { CLIENT_VERSION } from "../../version";

/**
* Engine client factory with `clientVersion: CLIENT_VERSION` injected.
*/
export function createClient(options: ClientOptions = {}): EngineClient {
return baseCreateClient({ clientVersion: CLIENT_VERSION, ...options });
}

/**
* Accounts client factory with `clientVersion: CLIENT_VERSION` injected.
*/
export function createAccountsClient(
options: AccountsClientOptions = {},
): AccountsClient {
return baseCreateAccountsClient({
clientVersion: CLIENT_VERSION,
...options,
});
}

/**
* Auth client factory.
*
* The device-flow endpoints don't go through the JSON-RPC pipeline, so they
* don't currently observe `X-Client-Version`. Re-exported here for symmetry
* so command files have a single import point.
*/
export function createAuthClient(options: AuthClientOptions = {}): AuthClient {
return baseCreateAuthClient(options);
}

// Re-export types and helpers used across the CLI. Pass-through so command
// files don't need to dual-import from "@memory.build/client".
export {
type AccountsClient,
type AccountsClientOptions,
type AuthClient,
type AuthClientOptions,
type CheckServerVersionOptions,
type ClientOptions,
checkServerVersion,
DeviceFlowError,
type EngineClient,
isRpcError,
RpcError,
} from "@memory.build/client";
2 changes: 1 addition & 1 deletion packages/cli/commands/apikey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
* - me apikey delete <id>: Permanently delete an API key
*/
import * as clack from "@clack/prompts";
import { createClient } from "@memory.build/client";
import { Command } from "commander";
import { createClient } from "../client.ts";
import { resolveCredentials } from "../credentials.ts";
import { getOutputFormat, output, table } from "../output.ts";
import {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/commands/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
* - me engine delete <id-or-name>: Permanently delete an engine
*/
import * as clack from "@clack/prompts";
import { createAccountsClient, RpcError } from "@memory.build/client";
import { Command } from "commander";
import { createAccountsClient, RpcError } from "../client.ts";
import {
getEngineApiKey,
resolveCredentials,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/commands/grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
* - me grant check <user> <path> <action>: Check access
*/
import * as clack from "@clack/prompts";
import { createClient } from "@memory.build/client";
import { Command } from "commander";
import { createClient } from "../client.ts";
import { resolveCredentials } from "../credentials.ts";
import { getOutputFormat, output, table } from "../output.ts";
import {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/commands/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
* -v, --verbose per-session progress lines
*/
import * as clack from "@clack/prompts";
import { createClient } from "@memory.build/client";
import { Command } from "commander";
import { createClient } from "../client.ts";
import { resolveCredentials } from "../credentials.ts";
import {
createProgressReporter,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/commands/invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
* - me invitation revoke <id>: Revoke an invitation
*/
import * as clack from "@clack/prompts";
import { createAccountsClient } from "@memory.build/client";
import { Command } from "commander";
import { createAccountsClient } from "../client.ts";
import { resolveCredentials } from "../credentials.ts";
import { getOutputFormat, output, table } from "../output.ts";
import { handleError, requireSession, resolveOrgId } from "../util.ts";
Expand Down
36 changes: 33 additions & 3 deletions packages/cli/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
* 6. Fetches and displays identity
*/
import * as clack from "@clack/prompts";
import type { OAuthProvider } from "@memory.build/protocol/auth/device-flow";
import { Command } from "commander";
import { CLIENT_VERSION, MIN_SERVER_VERSION } from "../../../version";
import {
checkServerVersion,
createAccountsClient,
createAuthClient,
DeviceFlowError,
} from "@memory.build/client";
import type { OAuthProvider } from "@memory.build/protocol/auth/device-flow";
import { Command } from "commander";
RpcError,
} from "../client.ts";
import {
getEngineApiKey,
resolveServer,
Expand Down Expand Up @@ -60,6 +63,33 @@ export function createLoginCommand(): Command {
clack.intro("me login");
}

// --- Compatibility check ---
// Verify that this CLI and the server agree on a compatible version
// before sending the user through the OAuth round-trip. Failing here
// is much friendlier than failing after they've authorized in their
// browser.
try {
await checkServerVersion({
url: server,
clientVersion: CLIENT_VERSION,
minServerVersion: MIN_SERVER_VERSION,
});
} catch (error) {
const msg =
error instanceof RpcError
? error.message
: error instanceof Error
? error.message
: String(error);
if (fmt === "text") {
clack.log.error(msg);
clack.outro("Login failed.");
} else {
output({ error: msg, server }, fmt, () => {});
}
process.exit(1);
}

// TODO: Re-enable Google OAuth once we have approved ToS/privacy policy
const provider: OAuthProvider = "github";

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/commands/memory-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { spawnSync } from "node:child_process";
import { unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { EngineClient } from "@memory.build/client";
import { stringify as yamlStringify } from "yaml";
import type { EngineClient } from "../client.ts";
import { parseMarkdown } from "../parsers/markdown.ts";

interface ParsedMemory {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/commands/memory-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { existsSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import * as clack from "@clack/prompts";
import { createClient } from "@memory.build/client";
import { Command } from "commander";
import { createClient } from "../client.ts";
import { resolveCredentials } from "../credentials.ts";
import { getOutputFormat, output } from "../output.ts";
import {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/commands/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import * as clack from "@clack/prompts";
import { createClient } from "@memory.build/client";
import { Command } from "commander";
import { stringify as yamlStringify } from "yaml";
import { createClient } from "../client.ts";
import { resolveCredentials } from "../credentials.ts";
import { getOutputFormat, output, table } from "../output.ts";
import { handleError, requireEngine, requireSession } from "../util.ts";
Expand Down
Loading