open-memex 0.4.0 → 0.5.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,14 @@
1
1
  // Quick smoke test — runs the pure-logic modules (no bun:sqlite dependency).
2
2
  // Usage: node --experimental-strip-types scripts\smoke-pure.ts
3
3
  import { parse, serialize, ulid, normalizeFrontmatter, msToRfc3339, timeToMs, parseRawFrontmatter, type Frontmatter } from "../src/store/markdown.ts";
4
- import { planConversion, isV2File } from "../src/store/v2migrate.ts";
4
+ import { planConversion, isV2File, migrateV2 } from "../src/store/v2migrate.ts";
5
5
  import { redact, findSecret } from "../src/redact.ts";
6
6
  import { detectKeywords } from "../src/capture/keywords.ts";
7
7
  import { DEFAULT_CONFIG } from "../src/config.ts";
8
8
  import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "../src/scope.ts";
9
9
  import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
10
10
  import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
11
+ import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, opencodeGlobalConfigPath } from "../src/init.ts";
11
12
 
12
13
  let fails = 0;
13
14
  function ok(name: string, cond: boolean, info?: unknown) {
@@ -314,6 +315,54 @@ const plan2 = planConversion(v1path, memRoot);
314
315
  ok("v2 on disk → null plan", plan2 === null);
315
316
  fs.rmSync(tmpRoot, { recursive: true, force: true });
316
317
 
318
+ console.log("== migrateV2 dry-run previews legacy files (issue #7) ==");
319
+ // legacy dir sits next to the new root (sibling), as on a real v1 upgrade
320
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "v2hotfix-"));
321
+ const newRoot = path.join(tmpHome, "open-memex");
322
+ const legacyRoot = path.join(tmpHome, "my-o-memory");
323
+ const legacyMem = path.join(legacyRoot, "memories", "user");
324
+ fs.mkdirSync(legacyMem, { recursive: true });
325
+ const v1legacy = `---\nid: 01HOTFIX1\nscope_key: user\nscope_kind: user\nproject_name: user\ntype: fact\npriority: 1\ncreated_at: 1758854400000\nupdated_at: 1758854400000\n---\n\nlegacy content\n`;
326
+ fs.writeFileSync(path.join(legacyMem, "01HOTFIX1.md"), v1legacy, "utf8");
327
+ process.env.MY_O_MEMORY_HOME = newRoot;
328
+
329
+ const dry = migrateV2({ dryRun: true });
330
+ ok("dry-run scans the legacy file", dry.scanned === 1 && dry.plans.length === 1, `scanned=${dry.scanned}`);
331
+ ok("dry-run yields a conversion plan", dry.plans.length === 1 && dry.plans[0].toPath.startsWith(newRoot), dry.plans[0]?.toPath);
332
+ ok("dry-run reports legacy backup target", typeof dry.legacyBackup === "string" && dry.legacyBackup.includes("backup-"), dry.legacyBackup);
333
+ ok("dry-run moves nothing", fs.existsSync(path.join(legacyMem, "01HOTFIX1.md")) && !fs.existsSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md")));
334
+ ok("dry-run does not rename legacy dir", fs.existsSync(legacyRoot) && !fs.existsSync(dry.legacyBackup!));
335
+
336
+ const real = migrateV2({ dryRun: false });
337
+ ok("real run converts", real.converted === 1, `converted=${real.converted}`);
338
+ ok("real run writes v2 file to new root", fs.existsSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md")));
339
+ ok("real run backs up legacy dir", !fs.existsSync(legacyRoot) && fs.existsSync(real.legacyBackup!));
340
+ const backContent = fs.readFileSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md"), "utf8");
341
+ ok("converted file has schema_version 2", backContent.includes("schema_version: 2"));
342
+
343
+ // backup rename failure → actionable Error, exit-1-worthy, no raw stack
344
+ // (same process/root: paths() is cached per process)
345
+ const legacyRoot2 = path.join(tmpHome, "my-o-memory");
346
+ const legacyMem2 = path.join(legacyRoot2, "memories", "user");
347
+ fs.mkdirSync(legacyMem2, { recursive: true });
348
+ fs.writeFileSync(path.join(legacyMem2, "01HOTFIX2.md"), v1legacy, "utf8");
349
+ // block the rename: pre-create today's dated backup as a non-empty dir
350
+ const blocked = path.join(tmpHome, "my-o-memory.backup-" + new Date().toISOString().slice(0, 10));
351
+ fs.mkdirSync(blocked, { recursive: true });
352
+ fs.writeFileSync(path.join(blocked, "sentinel"), "x", "utf8");
353
+ let boom: unknown = null;
354
+ try {
355
+ migrateV2({ dryRun: false });
356
+ } catch (e) {
357
+ boom = e;
358
+ }
359
+ ok("backup failure throws", boom instanceof Error, String(boom));
360
+ ok("backup failure message is actionable", boom instanceof Error && boom.message.includes("could not back up") && boom.message.includes("Your memories are safe"), boom instanceof Error ? boom.message.slice(0, 60) : "");
361
+ ok("failed backup keeps moved files safe in new root", fs.existsSync(path.join(newRoot, "memories", "user", "01HOTFIX2.md")));
362
+ ok("failed backup leaves legacy dir for retry", fs.existsSync(legacyRoot2));
363
+ delete process.env.MY_O_MEMORY_HOME;
364
+ fs.rmSync(tmpHome, { recursive: true, force: true });
365
+
317
366
  console.log("== lifecycle pure: contentHash / similarity ==");
318
367
  ok("hash deterministic + whitespace-insensitive", contentHash("hello world\n") === contentHash("hello world"));
319
368
  ok("hash differs on content", contentHash("hello world") !== contentHash("hello mars"));
@@ -328,5 +377,74 @@ const near = similarity(
328
377
  ok(`similarity near-dup ${near.toFixed(2)} >= ${NEAR_DUP_THRESHOLD}`, near >= NEAR_DUP_THRESHOLD);
329
378
  ok("similarity empty → 0", similarity("", "anything") === 0);
330
379
 
380
+ console.log("== init --global: userMcpConfigPath / mergeServerEntry ==");
381
+ // D45: user-level MCP config locations, per platform (platform param is injectable).
382
+ ok("vscode win32", userMcpConfigPath("vscode", "win32").endsWith(path.join("Code", "User", "mcp.json")));
383
+ ok("vscode darwin", userMcpConfigPath("vscode", "darwin").includes(path.join("Library", "Application Support", "Code", "User", "mcp.json")));
384
+ ok("vscode linux", userMcpConfigPath("vscode", "linux").endsWith(path.join(".config", "Code", "User", "mcp.json")));
385
+ ok("cursor is ~/.cursor/mcp.json on every platform",
386
+ (["win32", "darwin", "linux"] as const).every((p) =>
387
+ userMcpConfigPath("cursor", p).endsWith(path.join(".cursor", "mcp.json"))));
388
+ // mergeServerEntry: pure merge semantics.
389
+ const doc1: Record<string, unknown> = {};
390
+ ok("merge into empty doc adds", mergeServerEntry(doc1, "servers", { command: "x" }, false) === "added");
391
+ ok("entry landed under section", (doc1["servers"] as Record<string, unknown>)["open-memex"] !== undefined);
392
+ ok("existing entry kept without force", mergeServerEntry(doc1, "servers", { command: "y" }, false) === "kept");
393
+ ok("kept entry untouched", ((doc1["servers"] as Record<string, unknown>)["open-memex"] as Record<string, unknown>)["command"] === "x");
394
+ ok("force overwrites", mergeServerEntry(doc1, "servers", { command: "y" }, true) === "added");
395
+ ok("forced entry applied", ((doc1["servers"] as Record<string, unknown>)["open-memex"] as Record<string, unknown>)["command"] === "y");
396
+ const doc2: Record<string, unknown> = { servers: { other: { command: "z" } } };
397
+ ok("merge preserves sibling entries", mergeServerEntry(doc2, "servers", { command: "x" }, false) === "added"
398
+ && (doc2["servers"] as Record<string, unknown>)["other"] !== undefined);
399
+
400
+ console.log("== init D46: auto-detect + opencode global plugin ==");
401
+ // opencodeGlobalConfigPath: user-level location, XDG-aware.
402
+ const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "memex-smoke-"));
403
+ ok("opencode global config under ~/.config/opencode",
404
+ opencodeGlobalConfigPath(fakeHome).endsWith(path.join(".config", "opencode", "opencode.json")));
405
+ ok("opencode global config honors XDG_CONFIG_HOME", (() => {
406
+ const prev = process.env.XDG_CONFIG_HOME;
407
+ process.env.XDG_CONFIG_HOME = path.join(fakeHome, "xdg");
408
+ try {
409
+ return opencodeGlobalConfigPath(fakeHome).startsWith(path.join(fakeHome, "xdg"));
410
+ } finally {
411
+ if (prev === undefined) delete process.env.XDG_CONFIG_HOME;
412
+ else process.env.XDG_CONFIG_HOME = prev;
413
+ }
414
+ })());
415
+ // mergePluginEntry: pure merge semantics.
416
+ const pdoc: Record<string, unknown> = {};
417
+ ok("plugin merge creates array", mergePluginEntry(pdoc, "file:///x", false) === "added"
418
+ && JSON.stringify(pdoc["plugin"]) === JSON.stringify(["file:///x"]));
419
+ ok("plugin merge dup kept", mergePluginEntry(pdoc, "file:///x", false) === "kept");
420
+ ok("plugin merge force on dup adds nothing twice", mergePluginEntry(pdoc, "file:///x", true) === "added"
421
+ && (pdoc["plugin"] as unknown[]).length === 1);
422
+ const pdoc2: Record<string, unknown> = { plugin: ["file:///other"], theme: "dark" };
423
+ ok("plugin merge preserves siblings", mergePluginEntry(pdoc2, "file:///x", false) === "added"
424
+ && (pdoc2["plugin"] as unknown[]).length === 2 && pdoc2["theme"] === "dark");
425
+ // detectInstalledClients with a fully fake env.
426
+ const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "memex-bin-"));
427
+ fs.writeFileSync(path.join(binDir, "code"), "#!/bin/sh\n");
428
+ const detHome = fs.mkdtempSync(path.join(os.tmpdir(), "memex-home-"));
429
+ fs.mkdirSync(path.join(detHome, ".cursor"), { recursive: true });
430
+ fs.mkdirSync(path.join(detHome, ".config", "opencode"), { recursive: true });
431
+ const detRoot = fs.mkdtempSync(path.join(os.tmpdir(), "memex-root-"));
432
+ const detEnv = { pathEnv: binDir, home: detHome, platform: "linux" as const, root: detRoot, xdgConfigHome: path.join(detHome, ".config") };
433
+ const det = detectInstalledClients(detEnv);
434
+ ok("detects vscode via PATH", det.includes("vscode"));
435
+ ok("detects cursor via ~/.cursor", det.includes("cursor"));
436
+ ok("detects opencode via config dir", det.includes("opencode"));
437
+ ok("no visualstudio without .sln", !det.includes("visualstudio"));
438
+ fs.writeFileSync(path.join(detRoot, "app.sln"), "");
439
+ ok("visualstudio detected with .sln", detectInstalledClients(detEnv).includes("visualstudio"));
440
+ const emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), "memex-empty-"));
441
+ const detEmpty = detectInstalledClients({ pathEnv: "", home: emptyHome, platform: "linux", root: emptyHome, xdgConfigHome: path.join(emptyHome, ".config") });
442
+ ok("empty env detects nothing", detEmpty.length === 0);
443
+ const winBin = fs.mkdtempSync(path.join(os.tmpdir(), "memex-winbin-"));
444
+ fs.writeFileSync(path.join(winBin, "code.cmd"), "@echo off\n");
445
+ ok("win32 detects vscode via code.cmd",
446
+ detectInstalledClients({ pathEnv: winBin, home: emptyHome, platform: "win32", root: emptyHome, xdgConfigHome: path.join(emptyHome, ".config") }).includes("vscode"));
447
+ for (const d of [fakeHome, binDir, detHome, detRoot, emptyHome, winBin]) fs.rmSync(d, { recursive: true, force: true });
448
+
331
449
  console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILURES`);
332
450
  process.exit(fails === 0 ? 0 : 1);
package/src/cli.ts CHANGED
@@ -250,11 +250,15 @@ Usage: open-memex migrate [--from <key>] [--to <key>] [--dry-run] [--on-conflict
250
250
 
251
251
  Flags:
252
252
  --from / --to scope keys (default: current project → personal)
253
- --dry-run preview without moving anything
253
+ --dry-run preview without moving anything (--to-v2 previews the legacy
254
+ files in place, including per-file conversion plans)
254
255
  --on-conflict newer (default), overwrite, or skip
255
256
  --to-v2 convert a legacy my-o-memory data dir to the v2 layout
256
257
 
257
258
  Always preview with --dry-run first; nothing moves without confirmation.
259
+ On Windows, if another program holds the legacy folder open, the backup
260
+ rename fails with an actionable message instead of a stack trace — close
261
+ the program and re-run.
258
262
 
259
263
  Examples:
260
264
  open-memex migrate --dry-run
@@ -275,16 +279,21 @@ Examples:
275
279
  agent memory instructions. Existing files are merged, never clobbered.
276
280
 
277
281
  Usage: open-memex init [--client vscode|cursor|opencode|visualstudio]
278
- [--instructions personal|project] [--force] [--yes]
282
+ [--instructions personal|project] [--global] [--force] [--yes]
279
283
 
280
284
  Flags:
281
- --client editor to configure (default: auto-detect)
285
+ --client editor to configure (default: auto-detect all installed editors)
282
286
  --instructions personal (default, ~/.copilot/copilot-instructions.md) or project
287
+ --global write the MCP server entry to the editor's user-level config
288
+ (VS Code / Cursor) — init once, works in every project.
289
+ For opencode, --global wires the native plugin at user level
290
+ (~/.config/opencode/opencode.json) — no per-project init needed.
283
291
  --force overwrite existing config
284
292
  --yes accept all defaults, never prompt
285
293
 
286
294
  Examples:
287
295
  open-memex init
296
+ open-memex init --client vscode --global --yes
288
297
  open-memex init --client cursor --yes`,
289
298
 
290
299
  config: `Show config, or set a key.
@@ -349,16 +358,19 @@ Usage:
349
358
  Every command has its own help with description and examples:
350
359
  open-memex <command> --help (or -h)
351
360
 
352
- One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`) writes
353
- the MCP config for your editor (\`.vscode/mcp.json\`, \`.cursor/mcp.json\`,
354
- \`opencode.jsonc\`, or Visual Studio's solution-level \`.mcp.json\`) — no copy-paste
355
- needed. The Copilot memory instructions default to your user-level
361
+ One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`) detects
362
+ your installed editors and wires them all — user-level where the editor supports it
363
+ (VS Code / Cursor MCP config, opencode native plugin), so one init covers every project;
364
+ Visual Studio is included when the project has a solution file (its \`.mcp.json\`
365
+ stays solution-level by design). \`--client\` picks a single editor instead, and a
366
+ single-editor opencode init writes the per-project plain-MCP \`opencode.jsonc\`.
367
+ The Copilot memory instructions default to your user-level
356
368
  \`~/.copilot/copilot-instructions.md\` (all projects, never checked into a repo);
357
369
  \`--instructions project\` writes \`.github/copilot-instructions.md\` instead for
358
370
  teams where everyone uses open-memex.
359
371
  Existing files are merged, never clobbered; re-running is safe. On a terminal it
360
- asks which editor to set up and a couple of settings (keyword capture, first-turn
361
- injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
372
+ confirms the detected editors and asks a couple of settings (keyword capture,
373
+ first-turn injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
362
374
  \`open-memex config set <key> <value>\` changes those settings after install.
363
375
 
364
376
  Once installed globally (\`npm i -g open-memex@alpha\`) the \`open-memex\` command is
@@ -412,13 +424,20 @@ function parseFlags(argv: string[]): Record<string, string> {
412
424
  for (let i = 0; i < argv.length; i++) {
413
425
  const a = argv[i]!;
414
426
  if (a.startsWith("--")) {
415
- const key = a.slice(2);
427
+ const raw = a.slice(2);
428
+ const eq = raw.indexOf("=");
429
+ if (eq >= 0) {
430
+ // D44: accept --key=value as well as --key value (issue #7 — the
431
+ // = form was silently misparsed before, dropping the flag).
432
+ out[raw.slice(0, eq)] = raw.slice(eq + 1);
433
+ continue;
434
+ }
416
435
  const val = argv[i + 1];
417
436
  if (val !== undefined && !val.startsWith("--")) {
418
- out[key] = val;
437
+ out[raw] = val;
419
438
  i++;
420
439
  } else {
421
- out[key] = "true";
440
+ out[raw] = "true";
422
441
  }
423
442
  }
424
443
  }
@@ -548,7 +567,14 @@ async function main() {
548
567
  if (cmd === "migrate" && rest.includes("--to-v2")) {
549
568
  const flags = parseFlags(rest);
550
569
  const dryRun = flags["dry-run"] === "true";
551
- const stats = migrateV2({ dryRun });
570
+ let stats;
571
+ try {
572
+ stats = migrateV2({ dryRun });
573
+ } catch (err) {
574
+ // D44: actionable message, not a raw syscall stack (issue #7).
575
+ console.error(`Error: ${(err as Error).message}`);
576
+ process.exit(1);
577
+ }
552
578
  console.log(
553
579
  `${dryRun ? "DRY RUN: " : ""}scanned ${stats.scanned} files: ` +
554
580
  `${stats.converted} to convert, ${stats.skippedV2} already v2`,
@@ -562,7 +588,9 @@ async function main() {
562
588
  }
563
589
  if (stats.legacyBackup) {
564
590
  console.log(
565
- `\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`,
591
+ dryRun
592
+ ? `\nDRY RUN: legacy my-o-memory data dir found — it would be merged and backed up at:\n ${stats.legacyBackup}`
593
+ : `\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`,
566
594
  );
567
595
  }
568
596
  if (!dryRun && stats.converted > 0) {
@@ -575,7 +603,7 @@ async function main() {
575
603
  }
576
604
 
577
605
  // `init` is a pure file operation (§17 adoption path) — no DB needed.
578
- // Interactive when on a TTY (asks editor + settings); --yes skips prompts.
606
+ // Interactive when on a TTY (confirms detected editors + settings); --yes skips prompts.
579
607
  if (cmd === "init") {
580
608
  const flags = parseFlags(rest);
581
609
  const { initProject } = await import("./init.ts");
@@ -584,6 +612,7 @@ async function main() {
584
612
  force: flags["force"] === "true",
585
613
  yes: flags["yes"] === "true",
586
614
  instructions: flags["instructions"],
615
+ global: flags["global"] === "true",
587
616
  });
588
617
  return;
589
618
  }