Files
Rohit Ghumare 4b593e04d4 fix(cli): install pinned iii to private dir, fall back on PATH mismatch (#774)
* fix(cli): install pinned iii to private dir, fall back on PATH mismatch

Reported in #752: fresh global install of @agentmemory/agentmemory on a
box that already has iii-engine v0.16.1 on PATH refuses to boot because
agentmemory hard-pins v0.11.2. The downgrade hint tells the user to
overwrite their global iii install with v0.11.2, but the v0.11.2 release
ships only the 'iii' binary so any consumer of 'iii-init' / 'iii-worker'
breaks. AGENTMEMORY_III_VERSION=0.16.1 boots cleanly per the reporter,
but agentmemory's pin doesn't follow.

This change isolates agentmemory's pinned engine from the user's PATH.

  Install location:   ~/.agentmemory/bin/iii  (was: ~/.local/bin/iii)
  Fallback order:     private -> PATH -> ~/.local/bin -> /usr/local/bin
  PATH mismatch:      auto-install pinned to private (was: exit 1)
  Help / prompts:     point at the new private path

Behavior:

- pickCompatibleIii walks candidate iii paths and returns the first one
  whose --version matches IIPINNED_VERSION, or null if none match.
- When PATH iii mismatches the pin AND the private install doesn't
  exist, startEngine auto-installs to ~/.agentmemory/bin without
  prompting (the user's existing iii stays untouched).
- The 'attach to running engine' code still hard-fails if the running
  engine reports a wrong version — we can't reinstall under it without
  killing it. The error message now points at the private-install fix
  path instead of a manual curl.
- agentmemory remove now plans cleanup of BOTH ~/.agentmemory/bin/iii
  (always safe, agentmemory owns it) and ~/.local/bin/iii (legacy,
  version-gated so a user-managed install isn't deleted).

iii-sdk and engine version pins both stay at 0.11.2.

Closes #752.

* fix(cli): address review findings on iii pin resolution

- Attach path: read launched iii bin from engine-state.json before falling
  back to whichBinary/fallbackIiiPaths. Persist binPath in writeEngineState.
  Avoids false failures when PATH iii changed after the engine was started.
- resolveCompatibleIii: a failed iiiBinVersion probe (null) was treated as
  compatible. Require a positive match (detected === IIPINNED_VERSION) so
  unreadable / chmod-broken / crashed binaries route through the fallback +
  reinstall path instead of being silently trusted.
- remove-plan path helpers: append .exe on Windows. legacyLocalBinIii and
  privateIiiBin hard-coded 'iii' so existsSync probes missed the install on
  win32.
- probeLocalBinIiiVersion now reads legacyLocalBinIii(home) instead of the
  alias localBinIii (= privateIiiBin). The 'legacy version matches pin?'
  gate in buildRemovePlan was checking the private bin (which agentmemory
  always installs at the pin), so the legacy entry would render with the
  wrong description / alwaysAsk flag.
2026-06-02 09:13:39 +01:00

170 lines
6.0 KiB
TypeScript

// Unit tests for the `agentmemory remove` destruction plan.
//
// The plan module is pure-fs (just inspects what's present) so we sandbox
// a fake $HOME under tmpdir() and assert which plan items come back. The
// actual file deletion is wrapped in src/cli.ts.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
buildRemovePlan,
formatPlan,
type ConnectManifest,
type RemoveContext,
} from "../src/cli/remove-plan.js";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
let sandbox: string;
function ctx(overrides: Partial<RemoveContext> = {}): RemoveContext {
return {
home: sandbox,
pinnedVersion: "0.11.2",
localBinIiiVersion: null,
connectManifest: null,
...overrides,
};
}
function touch(relPath: string, content = ""): void {
const full = join(sandbox, relPath);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, content);
}
function mkdir(relPath: string): void {
mkdirSync(join(sandbox, relPath), { recursive: true });
}
beforeEach(() => {
sandbox = mkdtempSync(join(tmpdir(), "agentmemory-remove-"));
});
afterEach(() => {
rmSync(sandbox, { recursive: true, force: true });
});
describe("buildRemovePlan", () => {
it("returns no applicable items on a clean system", () => {
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const applicable = plan.filter((p) => p.applicable);
expect(applicable.length).toBe(0);
});
it("includes pidfile + engine-state when both exist", () => {
touch(".agentmemory/iii.pid", "12345\n");
touch(".agentmemory/engine-state.json", "{}");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const ids = plan.filter((p) => p.applicable).map((p) => p.id);
expect(ids).toContain("stop-engine");
expect(ids).toContain("pidfile");
expect(ids).toContain("engine-state");
});
it("marks .env as alwaysAsk", () => {
touch(".agentmemory/.env", "ANTHROPIC_API_KEY=sk-ant-real\n");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const envItem = plan.find((p) => p.id === "env")!;
expect(envItem.applicable).toBe(true);
expect(envItem.alwaysAsk).toBe(true);
});
it("--keep-data hides .env, preferences, backups, and data-dir", () => {
touch(".agentmemory/.env", "x");
touch(".agentmemory/preferences.json", "{}");
mkdir(".agentmemory/backups");
mkdir(".agentmemory/data");
const plan = buildRemovePlan(ctx(), { force: false, keepData: true });
const applicable = plan.filter((p) => p.applicable).map((p) => p.id);
expect(applicable).not.toContain("env");
expect(applicable).not.toContain("preferences");
expect(applicable).not.toContain("backups");
expect(applicable).not.toContain("data-dir");
});
it("data-dir is alwaysAsk even on --force", () => {
mkdir(".agentmemory/data");
const plan = buildRemovePlan(ctx(), { force: true, keepData: false });
const item = plan.find((p) => p.id === "data-dir")!;
expect(item.applicable).toBe(true);
expect(item.alwaysAsk).toBe(true);
});
it("expands connect-manifest entries into individual plan items", () => {
const manifest: ConnectManifest = {
installed: [
{ target: join(sandbox, "fake-claude-symlink"), agent: "claude-code", symlink: true },
{ target: join(sandbox, "fake-cursor-link"), agent: "cursor" },
],
};
touch("fake-claude-symlink");
touch("fake-cursor-link");
const plan = buildRemovePlan(ctx({ connectManifest: manifest }), {
force: false,
keepData: false,
});
const connectItems = plan.filter((p) => p.id.startsWith("connect:"));
expect(connectItems.length).toBe(2);
expect(connectItems.every((p) => p.applicable)).toBe(true);
});
it("local-bin/iii is alwaysAsk when version does not match", () => {
touch(".local/bin/iii", "fakebin");
const plan = buildRemovePlan(
ctx({ localBinIiiVersion: "9.9.9" }),
{ force: false, keepData: false },
);
const item = plan.find((p) => p.id === "legacy-local-bin-iii")!;
expect(item.applicable).toBe(true);
expect(item.alwaysAsk).toBe(true);
});
it("local-bin/iii is auto-fixable when version matches pinned", () => {
touch(".local/bin/iii", "fakebin");
const plan = buildRemovePlan(
ctx({ localBinIiiVersion: "0.11.2" }),
{ force: false, keepData: false },
);
const item = plan.find((p) => p.id === "legacy-local-bin-iii")!;
expect(item.applicable).toBe(true);
expect(item.alwaysAsk).toBe(false);
expect(item.description).toContain("matches pinned");
});
it("local-bin/iii absent: no plan entry created", () => {
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
expect(plan.find((p) => p.id === "legacy-local-bin-iii")).toBeUndefined();
expect(plan.find((p) => p.id === "private-bin-iii")).toBeUndefined();
});
it("private ~/.agentmemory/bin/iii is removed without prompt", () => {
touch(".agentmemory/bin/iii", "fakebin");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const item = plan.find((p) => p.id === "private-bin-iii")!;
expect(item).toBeDefined();
expect(item.applicable).toBe(true);
expect(item.alwaysAsk).toBe(false);
expect(item.description).toContain("private install");
});
});
describe("formatPlan", () => {
it("renders applicable items with numbers", () => {
touch(".agentmemory/iii.pid", "1");
touch(".agentmemory/engine-state.json", "{}");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const out = formatPlan(plan);
expect(out).toMatch(/^\s+1\./m);
expect(out).toContain("pidfile");
expect(out).toContain("engine-state.json");
});
it("marks alwaysAsk items with [asks]", () => {
touch(".agentmemory/.env", "x");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const out = formatPlan(plan);
expect(out).toContain("[asks]");
});
});