From 91207618b516caed5b19b9cde1ed1c5f118b204c Mon Sep 17 00:00:00 2001 From: Shah Hussain <95882307+shhahhussain@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:26:09 +0500 Subject: [PATCH 1/2] fix: send container scope on by-id memory routes (get/rm); bump getmnemo to ^0.5.1 GET/PATCH/DELETE /v1/memories/:id 400 without a containerTag or scopeType+scopeId query param (requireMemoryScope guard). The get and rm commands sent neither, so both failed against production. - add -C/--container to get and rm, resolved via the same resolveContainerTag precedence as add/search/list (flag > env > config) - exit 2 with the existing container-required message when nothing resolves - pass the env/config container into the Mnemo constructor as defaultContainerTag; per-call flag still wins - bump getmnemo ^0.2.0 -> ^0.5.1 for the options parameter on get/update/delete - tests assert the containerTag reaches the request query string --- package-lock.json | 8 +-- package.json | 2 +- src/cli.test.ts | 142 +++++++++++++++++++++++++++++++++++++++++ src/commands/memory.ts | 52 +++++++++------ src/lib/client.ts | 11 +++- 5 files changed, 190 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index d2bbae7..f314d3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "commander": "^12.1.0", - "getmnemo": "^0.2.0", + "getmnemo": "^0.5.1", "kleur": "^4.1.5", "prompts": "^2.4.2" }, @@ -1160,9 +1160,9 @@ } }, "node_modules/getmnemo": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/getmnemo/-/getmnemo-0.2.0.tgz", - "integrity": "sha512-envNOhd28zmeu7W30Keegen7TlIyOIdDo6GKvIoBZy/3FcZ4gLdasKf6HJ3V5wvujnSdoCAW8qFDKajC4nLrkA==", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/getmnemo/-/getmnemo-0.5.1.tgz", + "integrity": "sha512-Hrexlk8Bn1eK2ez0Cyfbm8uwLqq/CihrG0Hr1NAMlwd1ERXGB8YeIUYpqGZEyyYefrNSpbuXWJQrNI1yXcUTEA==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 48ae63a..bd29db7 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "commander": "^12.1.0", - "getmnemo": "^0.2.0", + "getmnemo": "^0.5.1", "kleur": "^4.1.5", "prompts": "^2.4.2" }, diff --git a/src/cli.test.ts b/src/cli.test.ts index 6ff8de6..26bfe6a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { buildCli } from "./cli.js"; describe("Mnemo CLI", () => { @@ -86,4 +88,144 @@ describe("Mnemo CLI", () => { expect(caught).toBeDefined(); expect((caught as { code?: string }).code).toBe("commander.unknownCommand"); }); + + describe("container scope on by-id memory commands", () => { + function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + function firstCall(fetchMock: ReturnType): [string, RequestInit] { + const call = fetchMock.mock.calls[0]; + if (!call) throw new Error("fetch was never called"); + return [String(call[0]), call[1] as RequestInit]; + } + + beforeEach(() => { + // Point HOME at a nonexistent dir so a real ~/.getmnemo/config.json + // (e.g. a developer's defaultContainerTag) can't leak into assertions. + vi.stubEnv("HOME", join(tmpdir(), "getmnemo-cli-test-home-nonexistent")); + vi.stubEnv("GETMNEMO_API_KEY", "mk_test_key"); + vi.stubEnv("GETMNEMO_WORKSPACE_ID", "ws_test"); + vi.stubEnv("GETMNEMO_API_URL", "https://api.test.invalid"); + vi.stubEnv("GETMNEMO_CONTAINER", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("get sends the --container tag as a containerTag query param", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ id: "mem_1", content: "hello" })); + vi.stubGlobal("fetch", fetchMock); + + const program = buildCli(); + program.exitOverride(); + await program.parseAsync([ + "node", "getmnemo", "--json", "get", "mem_1", "--container", "user:jane", + ]); + + const [url] = firstCall(fetchMock); + expect(url).toBe( + "https://api.test.invalid/v1/memories/mem_1?containerTag=user%3Ajane", + ); + expect(stdout).toMatch(/mem_1/); + }); + + it("get --container flag wins over GETMNEMO_CONTAINER", async () => { + vi.stubEnv("GETMNEMO_CONTAINER", "env:fallback"); + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ id: "mem_1", content: "hello" })); + vi.stubGlobal("fetch", fetchMock); + + const program = buildCli(); + program.exitOverride(); + await program.parseAsync([ + "node", "getmnemo", "--json", "get", "mem_1", "--container", "user:jane", + ]); + + const [url] = firstCall(fetchMock); + expect(url).toContain("containerTag=user%3Ajane"); + expect(url).not.toContain("env%3Afallback"); + }); + + it("get falls back to GETMNEMO_CONTAINER when no flag is given", async () => { + vi.stubEnv("GETMNEMO_CONTAINER", "user:env"); + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ id: "mem_1", content: "hello" })); + vi.stubGlobal("fetch", fetchMock); + + const program = buildCli(); + program.exitOverride(); + await program.parseAsync(["node", "getmnemo", "--json", "get", "mem_1"]); + + const [url] = firstCall(fetchMock); + expect(url).toContain("containerTag=user%3Aenv"); + }); + + it("get exits 2 with the container-required error when nothing resolves", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("__exit__"); + }) as never); + + const program = buildCli(); + program.exitOverride(); + try { + await program.parseAsync(["node", "getmnemo", "get", "mem_1"]); + } catch (err) { + expect((err as Error).message).toBe("__exit__"); + } + + expect(exitSpy).toHaveBeenCalledWith(2); + expect(stderr).toMatch(/A container is required/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rm --yes sends the container tag on the DELETE request", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + vi.stubGlobal("fetch", fetchMock); + + const program = buildCli(); + program.exitOverride(); + await program.parseAsync([ + "node", "getmnemo", "rm", "mem_9", "--yes", "--container", "user:jane", + ]); + + const [url, init] = firstCall(fetchMock); + expect(init.method).toBe("DELETE"); + expect(url).toBe( + "https://api.test.invalid/v1/memories/mem_9?containerTag=user%3Ajane", + ); + expect(stdout).toMatch(/Deleted mem_9/); + }); + + it("rm exits 2 without a container before sending any request", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("__exit__"); + }) as never); + + const program = buildCli(); + program.exitOverride(); + try { + await program.parseAsync(["node", "getmnemo", "rm", "mem_9", "--yes"]); + } catch (err) { + expect((err as Error).message).toBe("__exit__"); + } + + expect(exitSpy).toHaveBeenCalledWith(2); + expect(stderr).toMatch(/A container is required/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/commands/memory.ts b/src/commands/memory.ts index 6f9c98f..ac07679 100644 --- a/src/commands/memory.ts +++ b/src/commands/memory.ts @@ -3,7 +3,7 @@ import kleur from "kleur"; import prompts from "prompts"; import type { Memory, SearchHit } from "getmnemo"; import { getClient, parseMetadata } from "../lib/client.js"; -import { resolveContainerTag } from "../lib/config.js"; +import { resolveContainerTag, type CliConfig } from "../lib/config.js"; import { printError, printInfo, @@ -19,6 +19,19 @@ function memoryId(m: Memory | SearchHit): string { return "memoryId" in m ? m.memoryId : m.id; } +// The API rejects add/search and by-id get/delete without a scope, so resolve +// or exit(2) before the request. `list` is exempt — container is optional there. +function requireContainerTag(cfg: CliConfig, flag?: string): string { + const containerTag = resolveContainerTag(cfg, flag); + if (!containerTag) { + printError( + "A container is required. Pass --container , set GETMNEMO_CONTAINER, or add defaultContainerTag to your config.", + ); + process.exit(2); + } + return containerTag; +} + export function registerMemoryCommands(program: Command): void { program .command("add ") @@ -37,13 +50,7 @@ export function registerMemoryCommands(program: Command): void { const json = rootJsonFlag(cmd); const ctx = await getClient(); const metadata = parseMetadata(opts.metadata); - const containerTag = resolveContainerTag(ctx.cfg, opts.container); - if (!containerTag) { - printError( - "A container is required. Pass --container , set GETMNEMO_CONTAINER, or add defaultContainerTag to your config.", - ); - process.exit(2); - } + const containerTag = requireContainerTag(ctx.cfg, opts.container); const result = await ctx.client.add({ content, metadata, containerTag }); if (json) { printJson(result); @@ -79,13 +86,7 @@ export function registerMemoryCommands(program: Command): void { printError("--limit must be a positive integer"); process.exit(2); } - const containerTag = resolveContainerTag(ctx.cfg, opts.container); - if (!containerTag) { - printError( - "A container is required. Pass --container , set GETMNEMO_CONTAINER, or add defaultContainerTag to your config.", - ); - process.exit(2); - } + const containerTag = requireContainerTag(ctx.cfg, opts.container); // Field is `q` (NOT `query`) per the v0.2.0 contract. const result = await ctx.client.search({ q: query, limit, containerTag }); if (json) { @@ -115,12 +116,18 @@ export function registerMemoryCommands(program: Command): void { program .command("get ") .description("fetch a single memory by id") - .action(async (id: string, _opts: unknown, cmd: Command) => { + .option( + "-C, --container ", + "container tag / tenant boundary (e.g. user:jane); falls back to GETMNEMO_CONTAINER or config", + ) + .action(async (id: string, opts: { container?: string }, cmd: Command) => { const json = rootJsonFlag(cmd); const ctx = await getClient(); + // GET /v1/memories/:id 400s without a scope (requireMemoryScope guard). + const containerTag = requireContainerTag(ctx.cfg, opts.container); let found: Memory; try { - found = await ctx.client.get(id); + found = await ctx.client.get(id, { containerTag }); } catch (err: unknown) { const status = (err as { status?: number })?.status; if (status === 404) { @@ -145,9 +152,16 @@ export function registerMemoryCommands(program: Command): void { .command("rm ") .description("delete a memory") .option("-y, --yes", "skip confirmation prompt", false) - .action(async (id: string, opts: { yes?: boolean }, cmd: Command) => { + .option( + "-C, --container ", + "container tag / tenant boundary (e.g. user:jane); falls back to GETMNEMO_CONTAINER or config", + ) + .action(async (id: string, opts: { yes?: boolean; container?: string }, cmd: Command) => { const json = rootJsonFlag(cmd); const ctx = await getClient(); + // DELETE /v1/memories/:id 400s without a scope (requireMemoryScope + // guard). Resolve before prompting so the failure is immediate. + const containerTag = requireContainerTag(ctx.cfg, opts.container); if (!opts.yes) { if (!process.stdin.isTTY) { if (json) printJson({ ok: false, error: "confirmation_required" }); @@ -165,7 +179,7 @@ export function registerMemoryCommands(program: Command): void { return; } } - await ctx.client.delete(id); + await ctx.client.delete(id, { containerTag }); if (json) { printJson({ ok: true, id }); return; diff --git a/src/lib/client.ts b/src/lib/client.ts index a8871c7..f24e510 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -3,6 +3,7 @@ import { readConfig, resolveApiKey, resolveApiUrl, + resolveContainerTag, resolveWorkspaceId, type CliConfig, } from "./config.js"; @@ -39,7 +40,15 @@ export async function getClient(): Promise { ); } - const client = new Mnemo({ apiKey, workspaceId, baseUrl }); + // Seed the SDK's default container from env/config so by-id routes that + // require a scope still work when no per-command flag is given. A per-call + // containerTag (resolved with the --container flag) always wins over this. + const client = new Mnemo({ + apiKey, + workspaceId, + baseUrl, + defaultContainerTag: resolveContainerTag(cfg), + }); return { client, apiKey, workspaceId, baseUrl, cfg }; } From c8e125f41353f89f9096ca1dd7ab7fbeaf1f4d2c Mon Sep 17 00:00:00 2001 From: Shah Hussain <95882307+shhahhussain@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:35:42 +0500 Subject: [PATCH 2/2] fix: address review findings from the 0.5.1 bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review of the container-scope fix surfaced regressions the getmnemo ^0.2.0 -> ^0.5.1 bump introduced beyond the by-id routes: - login: the credential probe called list() with no container, which 0.5.1 rejects client-side — login failed for every user. Probe with a synthetic read-only tag (cli:login-probe) instead. - list: 0.5.1 list() throws without a container, so bare 'getmnemo list' died with a raw SDK error at exit 1. Gate it with the same container-required exit-2 message as the other commands. - prod smoke: cleanup ran 'rm --yes' with no --container (ids span two containers), leaking memories into the prod test workspace every CI run. Track id+container pairs and pass --container per delete. - rm: 0.5.1 deletes are recoverable by default; surface the receipt (restorable-until in human output, receipt in --json) and add --permanent for an immediate purge. - --json: container-required errors now emit {ok:false,error: "container_required"} instead of ANSI prose, matching the sibling error shapes. - tests: exit-code assertions use rejects.toThrow (the try/catch form passed vacuously when nothing threw); realistic delete fixtures; new coverage for list, login probe, --json error shape, --permanent. - dedupe the 5x-copied -C/--container option; fix stale comments; update README for the required-container contract. Deferred (tracked in PR body): workspaceId is deprecated/ignored by SDK 0.5.1 but still a hard CLI gate; add() strict receipt validation vs lagging self-hosted APIs. --- README.md | 19 +++--- scripts/prod-smoke.mjs | 17 ++++-- src/cli.test.ts | 135 +++++++++++++++++++++++++++++++++++++---- src/commands/auth.ts | 6 +- src/commands/memory.ts | 98 ++++++++++++++++-------------- src/lib/client.ts | 8 ++- 6 files changed, 208 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index f1c3586..41a48b5 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,10 @@ getmnemo search "what brand color does Acme use?" --container org:acme getmnemo doctor # verify auth + API reachability ``` -> `add` and `search` require a **container** (the tenant boundary, e.g. -> `user:jane` or `org:acme`). Pass `--container `, set `GETMNEMO_CONTAINER`, -> or add `defaultContainerTag` to `~/.getmnemo/config.json`. +> Every memory command (`add`, `search`, `get`, `rm`, `list`) requires a +> **container** (the tenant boundary, e.g. `user:jane` or `org:acme`). Pass +> `--container `, set `GETMNEMO_CONTAINER`, or add `defaultContainerTag` +> to `~/.getmnemo/config.json`. ## Commands @@ -47,11 +48,11 @@ getmnemo doctor # verify auth + API reachability | --- | --- | | `getmnemo add "" --container [-m key=value ...]` | Add a memory to a container with optional metadata. | | `getmnemo search "" --container [--limit 5]` | Semantic search within a container. | -| `getmnemo get ` | Fetch a single memory. | -| `getmnemo rm [--yes]` | Delete a memory. | -| `getmnemo list [--container ] [--limit 20] [--cursor ]` | Paginate the workspace (optionally filtered by container). | +| `getmnemo get --container ` | Fetch a single memory. | +| `getmnemo rm --container [--yes] [--permanent]` | Delete a memory (recoverable by default; `--permanent` purges immediately). | +| `getmnemo list --container [--limit 20] [--cursor ]` | Paginate the memories in a container. | -`--container` / `-C` accepts a container tag (e.g. `user:jane`). It is required on `add`/`search` and an optional filter on `list`. Resolution order: `--container` flag → `GETMNEMO_CONTAINER` env → `defaultContainerTag` in config. +`--container` / `-C` accepts a container tag (e.g. `user:jane`) and is required on every memory command. Resolution order: `--container` flag → `GETMNEMO_CONTAINER` env → `defaultContainerTag` in config. ### Workspaces @@ -88,7 +89,7 @@ getmnemo doctor # verify auth + API reachability | `GETMNEMO_API_KEY` | Overrides the saved API key. | — | | `GETMNEMO_WORKSPACE_ID` | Overrides the active workspace. | — | | `GETMNEMO_API_URL` | Overrides the API base URL. | `https://api.mnemohq.com` | -| `GETMNEMO_CONTAINER` | Default container tag for `add`/`search`/`list` when no `--container` flag is given. | — | +| `GETMNEMO_CONTAINER` | Default container tag for the memory commands when no `--container` flag is given. | — | Environment variables take precedence over `~/.getmnemo/config.json`. @@ -102,7 +103,7 @@ getmnemo add "Customer asked about SOC 2 timeline" --container org:acme -m chann getmnemo search "soc 2" --container org:acme --limit 3 --json | jq '.results[].memoryId' # delete without prompting (CI-safe) -getmnemo rm mem_01HX... --yes +getmnemo rm mem_01HX... --container org:acme --yes # generate Claude Desktop MCP config getmnemo mcp --client claude > claude_desktop_config.json diff --git a/scripts/prod-smoke.mjs b/scripts/prod-smoke.mjs index ba8a01c..563cac9 100644 --- a/scripts/prod-smoke.mjs +++ b/scripts/prod-smoke.mjs @@ -183,18 +183,23 @@ async function main() { console.log('[smoke] container B:', containerB) // Track created ids so cleanup runs even if assertions throw. - const createdIds = [] + // Track each created id WITH its container: `rm` requires a --container + // (by-id routes are scoped), and the two writes land in different ones. + const created = [] try { // ---- HAPPY PATH: add to two distinct containers -------------------- const addA = await runCli(['add', alphaContent, '--container', containerA], cliEnv) const addB = await runCli(['add', bravoContent, '--container', containerB], cliEnv) - createdIds.push(...addedIds(addA), ...addedIds(addB)) + created.push( + ...addedIds(addA).map((id) => ({ id, container: containerA })), + ...addedIds(addB).map((id) => ({ id, container: containerB })), + ) - if (createdIds.length < 2) { + if (created.length < 2) { fail( - `add did not return ids for both writes — got ${createdIds.length} ` + + `add did not return ids for both writes — got ${created.length} ` + `(addA.items=${addA?.items?.length ?? 0}, addB.items=${addB?.items?.length ?? 0})`, ) } @@ -235,9 +240,9 @@ async function main() { console.log('[smoke] OK isolation: A↛B and B↛A — no cross-container leakage') } finally { // ---- CLEANUP: best-effort delete; failure warns, never fatal ------- - for (const id of createdIds) { + for (const { id, container } of created) { try { - await runCli(['rm', id, '--yes'], cliEnv) + await runCli(['rm', id, '--yes', '--container', container], cliEnv) } catch (err) { const msg = err instanceof Error ? err.message : String(err) console.warn(`[smoke] WARN: cleanup delete failed for memory ${id}: ${msg}`) diff --git a/src/cli.test.ts b/src/cli.test.ts index 26bfe6a..9f1ed7a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import prompts from "prompts"; import { buildCli } from "./cli.js"; describe("Mnemo CLI", () => { @@ -179,19 +180,49 @@ describe("Mnemo CLI", () => { const program = buildCli(); program.exitOverride(); - try { - await program.parseAsync(["node", "getmnemo", "get", "mem_1"]); - } catch (err) { - expect((err as Error).message).toBe("__exit__"); - } + await expect( + program.parseAsync(["node", "getmnemo", "get", "mem_1"]), + ).rejects.toThrow("__exit__"); expect(exitSpy).toHaveBeenCalledWith(2); expect(stderr).toMatch(/A container is required/); expect(fetchMock).not.toHaveBeenCalled(); }); - it("rm --yes sends the container tag on the DELETE request", async () => { - const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + it("get --json emits a machine-readable container_required error", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("__exit__"); + }) as never); + + const program = buildCli(); + program.exitOverride(); + await expect( + program.parseAsync(["node", "getmnemo", "--json", "get", "mem_1"]), + ).rejects.toThrow("__exit__"); + + expect(exitSpy).toHaveBeenCalledWith(2); + expect(stdout).toMatch(/"error": "container_required"/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rm --yes sends the container tag on the DELETE and reports the recovery window", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + id: "mem_9", + deleted: true, + receipt: { + id: "mem_9", + eventId: "evt_1", + status: "restorable", + completedAt: "2026-08-19T00:00:00Z", + purged: {}, + recoveryId: "rec_1", + restorableUntil: "2026-09-01T00:00:00Z", + }, + }), + ); vi.stubGlobal("fetch", fetchMock); const program = buildCli(); @@ -205,6 +236,25 @@ describe("Mnemo CLI", () => { expect(url).toBe( "https://api.test.invalid/v1/memories/mem_9?containerTag=user%3Ajane", ); + expect(stdout).toMatch(/Deleted mem_9 \(restorable until 2026-09-01T00:00:00Z\)/); + }); + + it("rm --permanent sends permanent=true alongside the container tag", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ id: "mem_9", deleted: true })); + vi.stubGlobal("fetch", fetchMock); + + const program = buildCli(); + program.exitOverride(); + await program.parseAsync([ + "node", "getmnemo", "rm", "mem_9", "--yes", "--permanent", "--container", "user:jane", + ]); + + const [url] = firstCall(fetchMock); + expect(url).toBe( + "https://api.test.invalid/v1/memories/mem_9?permanent=true&containerTag=user%3Ajane", + ); expect(stdout).toMatch(/Deleted mem_9/); }); @@ -217,15 +267,76 @@ describe("Mnemo CLI", () => { const program = buildCli(); program.exitOverride(); - try { - await program.parseAsync(["node", "getmnemo", "rm", "mem_9", "--yes"]); - } catch (err) { - expect((err as Error).message).toBe("__exit__"); - } + await expect( + program.parseAsync(["node", "getmnemo", "rm", "mem_9", "--yes"]), + ).rejects.toThrow("__exit__"); + + expect(exitSpy).toHaveBeenCalledWith(2); + expect(stderr).toMatch(/A container is required/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("list sends the resolved container tag as a query param", async () => { + vi.stubEnv("GETMNEMO_CONTAINER", "user:env"); + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ items: [], nextCursor: null })); + vi.stubGlobal("fetch", fetchMock); + + const program = buildCli(); + program.exitOverride(); + await program.parseAsync(["node", "getmnemo", "--json", "list"]); + + const [url] = firstCall(fetchMock); + expect(url).toBe( + "https://api.test.invalid/v1/memories?limit=20&containerTag=user%3Aenv", + ); + }); + + it("list exits 2 with the CLI's container-required message when nothing resolves", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("__exit__"); + }) as never); + + const program = buildCli(); + program.exitOverride(); + await expect( + program.parseAsync(["node", "getmnemo", "list"]), + ).rejects.toThrow("__exit__"); expect(exitSpy).toHaveBeenCalledWith(2); expect(stderr).toMatch(/A container is required/); expect(fetchMock).not.toHaveBeenCalled(); }); + + it("login verifies credentials with a synthetic probe container", async () => { + // Own HOME so the config write cannot leak into the shared stub path. + vi.stubEnv( + "HOME", + join(tmpdir(), `getmnemo-cli-test-home-login-${process.pid}`), + ); + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ items: [], nextCursor: null })); + vi.stubGlobal("fetch", fetchMock); + // login prompts for the API URL (the key/workspace prompts are skipped + // by the flags); inject the answer so the test never blocks on stdin. + prompts.inject(["https://api.test.invalid"]); + + const program = buildCli(); + program.exitOverride(); + await program.parseAsync([ + "node", "getmnemo", "--json", "login", + "--api-key", "mk_login_test", "--workspace-id", "ws_login", + ]); + + const [url] = firstCall(fetchMock); + expect(url).toBe( + "https://api.test.invalid/v1/memories?limit=1&containerTag=cli%3Alogin-probe", + ); + expect(stdout).toMatch(/"ok": true/); + }); }); }); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 5d9155a..11f2373 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -63,7 +63,11 @@ export function registerAuthCommands(program: Command): void { // not silently overwrite a previously-working config. try { const probe = new Mnemo({ apiKey, workspaceId, baseUrl }); - await probe.list({ limit: 1 }); + // getmnemo 0.5.1 list() throws client-side without a container, and + // none is configured yet at login. Probe with a synthetic read-only + // tag: we only care whether the key authenticates (401 vs 2xx), and + // listing an empty container is a valid, cheap request. + await probe.list({ limit: 1, containerTag: "cli:login-probe" }); } catch (err: unknown) { const status = (err as { status?: number })?.status; const message = diff --git a/src/commands/memory.ts b/src/commands/memory.ts index ac07679..b73a998 100644 --- a/src/commands/memory.ts +++ b/src/commands/memory.ts @@ -19,14 +19,23 @@ function memoryId(m: Memory | SearchHit): string { return "memoryId" in m ? m.memoryId : m.id; } -// The API rejects add/search and by-id get/delete without a scope, so resolve -// or exit(2) before the request. `list` is exempt — container is optional there. -function requireContainerTag(cfg: CliConfig, flag?: string): string { +const CONTAINER_OPTION_FLAGS = "-C, --container "; +const CONTAINER_OPTION_DESC = + "container tag / tenant boundary (e.g. user:jane); falls back to GETMNEMO_CONTAINER or config"; + +// Every memory command needs a container as of getmnemo 0.5.1: the API 400s +// by-id get/delete without a scope (requireMemoryScope guard), and the SDK +// itself throws on add/search/list. Resolve or exit(2) before any request. +function requireContainerTag(cfg: CliConfig, flag: string | undefined, json: boolean): string { const containerTag = resolveContainerTag(cfg, flag); if (!containerTag) { - printError( - "A container is required. Pass --container , set GETMNEMO_CONTAINER, or add defaultContainerTag to your config.", - ); + if (json) { + printJson({ ok: false, error: "container_required" }); + } else { + printError( + "A container is required. Pass --container , set GETMNEMO_CONTAINER, or add defaultContainerTag to your config.", + ); + } process.exit(2); } return containerTag; @@ -37,10 +46,7 @@ export function registerMemoryCommands(program: Command): void { .command("add ") .description("add a new memory") .option("-m, --metadata ", "metadata key=value pairs (repeatable)") - .option( - "-C, --container ", - "container tag / tenant boundary (e.g. user:jane); falls back to GETMNEMO_CONTAINER or config", - ) + .option(CONTAINER_OPTION_FLAGS, CONTAINER_OPTION_DESC) .action( async ( content: string, @@ -50,7 +56,7 @@ export function registerMemoryCommands(program: Command): void { const json = rootJsonFlag(cmd); const ctx = await getClient(); const metadata = parseMetadata(opts.metadata); - const containerTag = requireContainerTag(ctx.cfg, opts.container); + const containerTag = requireContainerTag(ctx.cfg, opts.container, json); const result = await ctx.client.add({ content, metadata, containerTag }); if (json) { printJson(result); @@ -69,10 +75,7 @@ export function registerMemoryCommands(program: Command): void { .command("search ") .description("semantic search across memories") .option("-l, --limit ", "max results", "5") - .option( - "-C, --container ", - "container tag / tenant boundary (e.g. user:jane); falls back to GETMNEMO_CONTAINER or config", - ) + .option(CONTAINER_OPTION_FLAGS, CONTAINER_OPTION_DESC) .action( async ( query: string, @@ -86,8 +89,9 @@ export function registerMemoryCommands(program: Command): void { printError("--limit must be a positive integer"); process.exit(2); } - const containerTag = requireContainerTag(ctx.cfg, opts.container); - // Field is `q` (NOT `query`) per the v0.2.0 contract. + const containerTag = requireContainerTag(ctx.cfg, opts.container, json); + // Field is `q` (NOT `query`) per the API contract (re-verified + // against getmnemo 0.5.1). const result = await ctx.client.search({ q: query, limit, containerTag }); if (json) { printJson(result); @@ -116,15 +120,11 @@ export function registerMemoryCommands(program: Command): void { program .command("get ") .description("fetch a single memory by id") - .option( - "-C, --container ", - "container tag / tenant boundary (e.g. user:jane); falls back to GETMNEMO_CONTAINER or config", - ) + .option(CONTAINER_OPTION_FLAGS, CONTAINER_OPTION_DESC) .action(async (id: string, opts: { container?: string }, cmd: Command) => { const json = rootJsonFlag(cmd); const ctx = await getClient(); - // GET /v1/memories/:id 400s without a scope (requireMemoryScope guard). - const containerTag = requireContainerTag(ctx.cfg, opts.container); + const containerTag = requireContainerTag(ctx.cfg, opts.container, json); let found: Memory; try { found = await ctx.client.get(id, { containerTag }); @@ -150,18 +150,19 @@ export function registerMemoryCommands(program: Command): void { program .command("rm ") - .description("delete a memory") + .description("delete a memory (recoverable by default; --permanent purges)") .option("-y, --yes", "skip confirmation prompt", false) - .option( - "-C, --container ", - "container tag / tenant boundary (e.g. user:jane); falls back to GETMNEMO_CONTAINER or config", - ) - .action(async (id: string, opts: { yes?: boolean; container?: string }, cmd: Command) => { + .option("-P, --permanent", "purge immediately instead of a recoverable delete", false) + .option(CONTAINER_OPTION_FLAGS, CONTAINER_OPTION_DESC) + .action(async ( + id: string, + opts: { yes?: boolean; permanent?: boolean; container?: string }, + cmd: Command, + ) => { const json = rootJsonFlag(cmd); const ctx = await getClient(); - // DELETE /v1/memories/:id 400s without a scope (requireMemoryScope - // guard). Resolve before prompting so the failure is immediate. - const containerTag = requireContainerTag(ctx.cfg, opts.container); + // Resolve before prompting so a missing container fails immediately. + const containerTag = requireContainerTag(ctx.cfg, opts.container, json); if (!opts.yes) { if (!process.stdin.isTTY) { if (json) printJson({ ok: false, error: "confirmation_required" }); @@ -171,7 +172,7 @@ export function registerMemoryCommands(program: Command): void { const { confirm } = await prompts({ type: "confirm", name: "confirm", - message: `Delete memory ${id}?`, + message: `${opts.permanent ? "Permanently delete" : "Delete"} memory ${id}?`, initial: false, }); if (!confirm) { @@ -179,23 +180,31 @@ export function registerMemoryCommands(program: Command): void { return; } } - await ctx.client.delete(id, { containerTag }); + const result = await ctx.client.delete(id, { + containerTag, + permanent: opts.permanent === true, + }); if (json) { - printJson({ ok: true, id }); + printJson({ ok: true, id, receipt: result.receipt ?? null }); return; } - printSuccess(`Deleted ${id}`); + // Deletion is recoverable by default when the workspace has a recovery + // window — say so instead of implying a completed purge. + const restorableUntil = + result.receipt?.status === "restorable" ? result.receipt.restorableUntil : undefined; + printSuccess( + restorableUntil + ? `Deleted ${id} (restorable until ${restorableUntil})` + : `Deleted ${id}`, + ); }); program .command("list") - .description("list memories in the current workspace") + .description("list memories in a container") .option("-l, --limit ", "page size", "20") .option("-c, --cursor ", "pagination cursor") - .option( - "-C, --container ", - "filter by container tag / tenant boundary (e.g. user:jane); falls back to GETMNEMO_CONTAINER or config", - ) + .option(CONTAINER_OPTION_FLAGS, CONTAINER_OPTION_DESC) .action( async ( opts: { limit?: string; cursor?: string; container?: string }, @@ -208,9 +217,10 @@ export function registerMemoryCommands(program: Command): void { printError("--limit must be a positive integer"); process.exit(2); } - // Container is an optional filter on list — undefined lists the - // workspace; a value scopes to that tenant boundary. - const containerTag = resolveContainerTag(ctx.cfg, opts.container); + // As of getmnemo 0.5.1 list() requires a container (the SDK throws + // without one) — gate it like the other commands so the failure is + // the CLI's exit-2 message, not a raw SDK error. + const containerTag = requireContainerTag(ctx.cfg, opts.container, json); const result = await ctx.client.list({ limit, cursor: opts.cursor, diff --git a/src/lib/client.ts b/src/lib/client.ts index f24e510..4268ec0 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -40,9 +40,11 @@ export async function getClient(): Promise { ); } - // Seed the SDK's default container from env/config so by-id routes that - // require a scope still work when no per-command flag is given. A per-call - // containerTag (resolved with the --container flag) always wins over this. + // Backstop only: every current command resolves its container per call + // (flag > env > config) and passes it explicitly, so this seed is not + // consulted today. It exists so any future SDK call that omits a per-call + // container falls back to the user's documented env/config default instead + // of an SDK error. Per-call values always win over this. const client = new Mnemo({ apiKey, workspaceId,