4b593e04d4
* 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.
170 lines
6.0 KiB
TypeScript
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]");
|
|
});
|
|
});
|