diff --git a/package.json b/package.json index 77185ad5f..2c1b2d407 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "agentmemory": "dist/cli.mjs" }, "scripts": { - "build": "tsdown && (cp iii-config.yaml dist/ 2>/dev/null || true) && (cp iii-config.docker.yaml dist/ 2>/dev/null || true) && (cp docker-compose.yml dist/ 2>/dev/null || true) && (cp .env.example dist/ 2>/dev/null || true) && mkdir -p dist/viewer && cp src/viewer/index.html dist/viewer/ && cp src/viewer/favicon.svg dist/viewer/", + "build": "tsdown && node scripts/copy-assets.mjs", "dev": "tsx src/index.ts", "start": "node dist/cli.mjs", "migrate": "node dist/functions/migrate.js", diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs new file mode 100644 index 000000000..b488105f6 --- /dev/null +++ b/scripts/copy-assets.mjs @@ -0,0 +1,28 @@ +import { copyFile, mkdir } from "node:fs/promises"; +import { resolve, join } from "node:path"; + +const root = process.cwd(); +const dist = resolve(root, "dist"); + +const optionalFiles = [ + "iii-config.yaml", + "iii-config.docker.yaml", + "docker-compose.yml", + ".env.example", +]; + +await mkdir(join(dist, "viewer"), { recursive: true }); + +for (const file of optionalFiles) { + try { + await copyFile(join(root, file), join(dist, file)); + } catch (err) { + // These files are optional; ignore if they don't exist. + if (err.code !== "ENOENT") { + throw err; + } + } +} + +await copyFile(join(root, "src/viewer/index.html"), join(dist, "viewer", "index.html")); +await copyFile(join(root, "src/viewer/favicon.svg"), join(dist, "viewer", "favicon.svg")); diff --git a/test/cli-remove.test.ts b/test/cli-remove.test.ts index 2484d4f88..6543ed5b8 100644 --- a/test/cli-remove.test.ts +++ b/test/cli-remove.test.ts @@ -27,6 +27,8 @@ function ctx(overrides: Partial = {}): RemoveContext { }; } +const III_BIN = process.platform === "win32" ? "iii.exe" : "iii"; + function touch(relPath: string, content = ""): void { const full = join(sandbox, relPath); mkdirSync(join(full, ".."), { recursive: true }); @@ -110,7 +112,7 @@ describe("buildRemovePlan", () => { }); it("local-bin/iii is alwaysAsk when version does not match", () => { - touch(".local/bin/iii", "fakebin"); + touch(`.local/bin/${III_BIN}`, "fakebin"); const plan = buildRemovePlan( ctx({ localBinIiiVersion: "9.9.9" }), { force: false, keepData: false }, @@ -121,7 +123,7 @@ describe("buildRemovePlan", () => { }); it("local-bin/iii is auto-fixable when version matches pinned", () => { - touch(".local/bin/iii", "fakebin"); + touch(`.local/bin/${III_BIN}`, "fakebin"); const plan = buildRemovePlan( ctx({ localBinIiiVersion: "0.11.2" }), { force: false, keepData: false }, @@ -139,7 +141,7 @@ describe("buildRemovePlan", () => { }); it("private ~/.agentmemory/bin/iii is removed without prompt", () => { - touch(".agentmemory/bin/iii", "fakebin"); + touch(`.agentmemory/bin/${III_BIN}`, "fakebin"); const plan = buildRemovePlan(ctx(), { force: false, keepData: false }); const item = plan.find((p) => p.id === "private-bin-iii")!; expect(item).toBeDefined(); diff --git a/test/compress-file.test.ts b/test/compress-file.test.ts index 9b6820b3e..526ec8ff6 100644 --- a/test/compress-file.test.ts +++ b/test/compress-file.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolve, dirname, basename, join } from "node:path"; vi.mock("../src/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -87,6 +88,16 @@ function mockSdk() { }; } +const notesPath = resolve("/tmp/notes.md"); +const notesBackupPath = join(dirname(notesPath), `${basename(notesPath, ".md")}.original.md`); +const notesOriginalPath = resolve("/tmp/notes.original.md"); +const notesOriginalBackupPath = join( + dirname(notesOriginalPath), + `${basename(notesOriginalPath, ".md")}.backup.md`, +); +const guidePath = resolve("/tmp/guide.md"); +const guideBackupPath = join(dirname(guidePath), `${basename(guidePath, ".md")}.original.md`); + describe("mem::compress-file", () => { let sdk: ReturnType; let kv: ReturnType; @@ -107,9 +118,9 @@ describe("mem::compress-file", () => { }); it("rejects symlinks", async () => { - symlinkPaths.add("/tmp/notes.md"); + symlinkPaths.add(notesPath); const result = (await sdk.trigger("mem::compress-file", { - filePath: "/tmp/notes.md", + filePath: notesPath, })) as { success: boolean; error: string }; expect(result.success).toBe(false); expect(result.error).toContain("symlink"); @@ -118,18 +129,17 @@ describe("mem::compress-file", () => { }); it("rejects TOCTOU symlink swap at write time via O_NOFOLLOW", async () => { - const path = "/tmp/notes.md"; fileStore.set( - path, + notesPath, "# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nContent.", ); summarize.mockResolvedValue( "# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nShort.", ); - openEloopPaths.add(path); + openEloopPaths.add(notesPath); const result = (await sdk.trigger("mem::compress-file", { - filePath: path, + filePath: notesPath, })) as { success: boolean; error: string }; expect(result.success).toBe(false); expect(result.error).toContain("symlink"); @@ -137,7 +147,7 @@ describe("mem::compress-file", () => { it("rejects non-markdown paths", async () => { const result = (await sdk.trigger("mem::compress-file", { - filePath: "/tmp/readme.txt", + filePath: resolve("/tmp/readme.txt"), })) as { success: boolean; error: string }; expect(result.success).toBe(false); expect(result.error).toContain(".md"); @@ -145,16 +155,15 @@ describe("mem::compress-file", () => { it("returns file not found for missing paths", async () => { const result = (await sdk.trigger("mem::compress-file", { - filePath: "/tmp/nonexistent.md", + filePath: resolve("/tmp/nonexistent.md"), })) as { success: boolean; error: string }; expect(result.success).toBe(false); expect(result.error).toContain("not found"); }); it("compresses markdown and writes .original.md backup", async () => { - const path = "/tmp/notes.md"; fileStore.set( - path, + notesPath, "# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nSome long explanation.", ); @@ -163,7 +172,7 @@ describe("mem::compress-file", () => { ); const result = (await sdk.trigger("mem::compress-file", { - filePath: path, + filePath: notesPath, })) as { success: boolean; backupPath: string; @@ -172,41 +181,39 @@ describe("mem::compress-file", () => { }; expect(result.success).toBe(true); - expect(result.backupPath).toBe("/tmp/notes.original.md"); - expect(fileStore.get("/tmp/notes.original.md")).toContain("Some long explanation."); - expect(fileStore.get(path)).toContain("Short explanation."); + expect(result.backupPath).toBe(notesBackupPath); + expect(fileStore.get(notesBackupPath)).toContain("Some long explanation."); + expect(fileStore.get(notesPath)).toContain("Short explanation."); expect(result.compressedChars).toBeLessThan(result.originalChars); }); it("fails validation when URLs change", async () => { - const path = "/tmp/guide.md"; - fileStore.set(path, "# Guide\n\nhttps://example.com\n"); + fileStore.set(guidePath, "# Guide\n\nhttps://example.com\n"); summarize.mockResolvedValue("# Guide\n\nhttps://different.example.com\n"); const result = (await sdk.trigger("mem::compress-file", { - filePath: path, + filePath: guidePath, })) as { success: boolean; error: string; details: string[] }; expect(result.success).toBe(false); expect(result.error).toContain("validation"); expect(result.details.some((d) => d.includes("url"))).toBe(true); - expect(fileStore.get("/tmp/guide.original.md")).toBeUndefined(); + expect(fileStore.get(guideBackupPath)).toBeUndefined(); }); it("uses a distinct backup path for *.original.md inputs", async () => { - const path = "/tmp/notes.original.md"; - fileStore.set(path, "# Title\n\nLong original body."); + fileStore.set(notesOriginalPath, "# Title\n\nLong original body."); summarize.mockResolvedValue("# Title\n\nShort body."); const result = (await sdk.trigger("mem::compress-file", { - filePath: path, + filePath: notesOriginalPath, })) as { success: boolean; backupPath: string }; expect(result.success).toBe(true); - expect(result.backupPath).toBe("/tmp/notes.original.backup.md"); - expect(fileStore.get("/tmp/notes.original.backup.md")).toBe( + expect(result.backupPath).toBe(notesOriginalBackupPath); + expect(fileStore.get(notesOriginalBackupPath)).toBe( "# Title\n\nLong original body.", ); - expect(fileStore.get(path)).toBe("# Title\n\nShort body."); + expect(fileStore.get(notesOriginalPath)).toBe("# Title\n\nShort body."); }); }); diff --git a/test/copilot-plugin.test.ts b/test/copilot-plugin.test.ts index cd01b2d87..b19d02664 100644 --- a/test/copilot-plugin.test.ts +++ b/test/copilot-plugin.test.ts @@ -288,14 +288,14 @@ describe("Copilot hook scripts", () => { const result = await runHook( "scripts/session-start.mjs", { sessionId: "copilot-session", cwd: "C:\\repo" }, - { AGENTMEMORY_INJECT_CONTEXT: "true" }, + { AGENTMEMORY_INJECT_CONTEXT: "true", AGENTMEMORY_PROJECT_NAME: "" }, ); expect(result.stdout).toBe("remembered context"); expect(result.requests[0]?.path).toBe("/agentmemory/session/start"); expect(result.requests[0]?.body).toMatchObject({ sessionId: "copilot-session", - project: "C:\\repo", + project: process.platform === "win32" ? "repo" : "C:\\repo", cwd: "C:\\repo", }); }); diff --git a/test/obsidian-export.test.ts b/test/obsidian-export.test.ts index 31394bca9..6b096fcaf 100644 --- a/test/obsidian-export.test.ts +++ b/test/obsidian-export.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; vi.mock("../src/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -121,7 +123,7 @@ function makeSession(id: string): Session { describe("Obsidian Export", () => { let sdk: ReturnType; let kv: ReturnType; - const exportRoot = "/tmp/agentmemory-export-root"; + const exportRoot = join(tmpdir(), "agentmemory-export-root"); beforeEach(() => { process.env.AGENTMEMORY_EXPORT_ROOT = exportRoot; @@ -164,7 +166,7 @@ describe("Obsidian Export", () => { expect(result.exported.memories).toBe(1); const memFile = [...writtenFiles.entries()].find(([k]) => - k.includes("memories/mem_001.md"), + k.includes(join("memories", "mem_001.md")), ); expect(memFile).toBeDefined(); const content = memFile![1]; @@ -186,7 +188,7 @@ describe("Obsidian Export", () => { expect(result.exported.lessons).toBe(1); const lsnFile = [...writtenFiles.entries()].find(([k]) => - k.includes("lessons/lsn_001.md"), + k.includes(join("lessons", "lsn_001.md")), ); expect(lsnFile).toBeDefined(); const content = lsnFile![1]; @@ -202,7 +204,7 @@ describe("Obsidian Export", () => { await sdk.trigger("mem::obsidian-export", {}); const crysFile = [...writtenFiles.entries()].find(([k]) => - k.includes("crystals/crys_001.md"), + k.includes(join("crystals", "crys_001.md")), ); expect(crysFile).toBeDefined(); expect(crysFile![1]).toContain("[[act_1]]"); @@ -222,19 +224,20 @@ describe("Obsidian Export", () => { }); it("respects custom vaultDir", async () => { + const customVaultDir = join(exportRoot, "test-vault"); await sdk.trigger("mem::obsidian-export", { - vaultDir: "/tmp/agentmemory-export-root/test-vault", + vaultDir: customVaultDir, }); const hasCustomPath = [...createdDirs].some((d) => - d.startsWith("/tmp/agentmemory-export-root/test-vault"), + d.startsWith(customVaultDir), ); expect(hasCustomPath).toBe(true); }); it("rejects vaultDir outside the export root", async () => { const result = (await sdk.trigger("mem::obsidian-export", { - vaultDir: "/tmp/outside-root", + vaultDir: join(tmpdir(), "outside-root"), })) as { success: boolean; error: string }; expect(result.success).toBe(false); @@ -323,7 +326,7 @@ describe("Obsidian Export", () => { expect(result.exported.sessions).toBe(1); expect(result.errors).toBeUndefined(); expect([...writtenFiles.keys()].some((path) => path.includes("undefined.md"))).toBe(false); - expect([...writtenFiles.keys()].some((path) => path.includes("sessions/ses_valid.md"))).toBe(true); + expect([...writtenFiles.keys()].some((path) => path.includes(join("sessions", "ses_valid.md")))).toBe(true); }); it("tolerates malformed startedAt timestamps when sorting sessions", async () => { @@ -359,7 +362,7 @@ describe("Obsidian Export", () => { expect(result.exported.memories).toBe(1); const memFile = [...writtenFiles.entries()].find(([k]) => - k.includes("memories/mem_incomplete.md"), + k.includes(join("memories", "mem_incomplete.md")), ); expect(memFile).toBeDefined(); const content = memFile![1]; @@ -394,17 +397,17 @@ describe("Obsidian Export", () => { expect(result.exported.crystals).toBe(1); const memFile = [...writtenFiles.entries()].find(([k]) => - k.includes("memories/mem_no_title.md"), + k.includes(join("memories", "mem_no_title.md")), ); expect(memFile![1]).toContain("# mem_no_title"); const lsnFile = [...writtenFiles.entries()].find(([k]) => - k.includes("lessons/lsn_no_content.md"), + k.includes(join("lessons", "lsn_no_content.md")), ); expect(lsnFile![1]).toContain("# Lesson: lsn_no_content"); const crysFile = [...writtenFiles.entries()].find(([k]) => - k.includes("crystals/crys_no_narr.md"), + k.includes(join("crystals", "crys_no_narr.md")), ); expect(crysFile![1]).toContain("# Crystal: crys_no_narr"); }); diff --git a/test/slots-flag-gate.test.ts b/test/slots-flag-gate.test.ts index 287bcbd89..c586ffbcc 100644 --- a/test/slots-flag-gate.test.ts +++ b/test/slots-flag-gate.test.ts @@ -13,20 +13,26 @@ import { join } from "node:path"; describe("isSlotsEnabled — reads merged env (#678)", () => { let home: string; let ORIG_HOME: string | undefined; + let ORIG_USERPROFILE: string | undefined; let ORIG_FLAG: string | undefined; beforeEach(() => { home = mkdtempSync(join(tmpdir(), "am-slots-flag-")); mkdirSync(join(home, ".agentmemory"), { recursive: true }); ORIG_HOME = process.env["HOME"]; + ORIG_USERPROFILE = process.env["USERPROFILE"]; ORIG_FLAG = process.env["AGENTMEMORY_SLOTS"]; process.env["HOME"] = home; + process.env["USERPROFILE"] = home; delete process.env["AGENTMEMORY_SLOTS"]; vi.resetModules(); }); afterEach(() => { if (ORIG_HOME !== undefined) process.env["HOME"] = ORIG_HOME; + else delete process.env["HOME"]; + if (ORIG_USERPROFILE !== undefined) process.env["USERPROFILE"] = ORIG_USERPROFILE; + else delete process.env["USERPROFILE"]; if (ORIG_FLAG !== undefined) process.env["AGENTMEMORY_SLOTS"] = ORIG_FLAG; else delete process.env["AGENTMEMORY_SLOTS"]; rmSync(home, { recursive: true, force: true }); @@ -60,20 +66,26 @@ describe("isSlotsEnabled — reads merged env (#678)", () => { describe("isReflectEnabled — reads merged env (#678)", () => { let home: string; let ORIG_HOME: string | undefined; + let ORIG_USERPROFILE: string | undefined; let ORIG_FLAG: string | undefined; beforeEach(() => { home = mkdtempSync(join(tmpdir(), "am-reflect-flag-")); mkdirSync(join(home, ".agentmemory"), { recursive: true }); ORIG_HOME = process.env["HOME"]; + ORIG_USERPROFILE = process.env["USERPROFILE"]; ORIG_FLAG = process.env["AGENTMEMORY_REFLECT"]; process.env["HOME"] = home; + process.env["USERPROFILE"] = home; delete process.env["AGENTMEMORY_REFLECT"]; vi.resetModules(); }); afterEach(() => { if (ORIG_HOME !== undefined) process.env["HOME"] = ORIG_HOME; + else delete process.env["HOME"]; + if (ORIG_USERPROFILE !== undefined) process.env["USERPROFILE"] = ORIG_USERPROFILE; + else delete process.env["USERPROFILE"]; if (ORIG_FLAG !== undefined) process.env["AGENTMEMORY_REFLECT"] = ORIG_FLAG; else delete process.env["AGENTMEMORY_REFLECT"]; rmSync(home, { recursive: true, force: true });