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.
- package/AGENTS.md +13 -7
- package/CONTRIBUTING.md +10 -10
- package/README.md +54 -8
- package/README.zh-CN.md +51 -7
- package/dist/cli.js +45 -15
- package/dist/init.js +299 -30
- package/dist/store/v2migrate.js +83 -34
- package/docs/TEST-PLAN.md +2 -0
- package/docs/V2-DESIGN.md +52 -0
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +119 -1
- package/src/cli.ts +44 -15
- package/src/init.ts +327 -27
- package/src/store/v2migrate.ts +88 -34
package/src/init.ts
CHANGED
|
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
7
8
|
import { DEFAULT_CONFIG, saveConfig } from "./config.ts";
|
|
8
9
|
import { projectRoot } from "./paths.ts";
|
|
9
10
|
|
|
@@ -110,41 +111,191 @@ function writeMcpJson(root: string, client: string, force: boolean): string | nu
|
|
|
110
111
|
const dir = client === "cursor" ? path.join(root, ".cursor") : path.join(root, ".vscode");
|
|
111
112
|
const file = path.join(dir, "mcp.json");
|
|
112
113
|
const sectionKey = client === "cursor" ? "mcpServers" : "servers";
|
|
114
|
+
const { entry, durable } = stdioServerEntry(client);
|
|
115
|
+
const written = writeServerEntryFile(file, sectionKey, entry, force, true);
|
|
116
|
+
if (written) warnNonDurable(durable);
|
|
117
|
+
return written;
|
|
118
|
+
}
|
|
113
119
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
/**
|
|
121
|
+
* D45: user-level MCP config path — `init --global` writes here so one init
|
|
122
|
+
* covers all projects. `platform` and `home` are parameters (default: current)
|
|
123
|
+
* so tests can cover all OS layouts without mocking.
|
|
124
|
+
*/
|
|
125
|
+
export function userMcpConfigPath(
|
|
126
|
+
client: "vscode" | "cursor",
|
|
127
|
+
platform: NodeJS.Platform = process.platform,
|
|
128
|
+
home: string = os.homedir(),
|
|
129
|
+
): string {
|
|
130
|
+
if (client === "cursor") return path.join(home, ".cursor", "mcp.json");
|
|
131
|
+
if (platform === "win32") {
|
|
132
|
+
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
133
|
+
return path.join(appData, "Code", "User", "mcp.json");
|
|
122
134
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (section["open-memex"] && !force) {
|
|
126
|
-
console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
|
|
127
|
-
return file;
|
|
135
|
+
if (platform === "darwin") {
|
|
136
|
+
return path.join(home, "Library", "Application Support", "Code", "User", "mcp.json");
|
|
128
137
|
}
|
|
138
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
139
|
+
return path.join(xdg, "Code", "User", "mcp.json");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The open-memex server entry, same shape init writes at project level. */
|
|
143
|
+
function stdioServerEntry(client: string): { entry: Record<string, unknown>; durable: boolean } {
|
|
129
144
|
// D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
|
|
130
145
|
const mc = resolveMcpCommand();
|
|
131
|
-
|
|
146
|
+
const entry: Record<string, unknown> =
|
|
132
147
|
client === "cursor"
|
|
133
148
|
? { command: mc.command, args: mc.args }
|
|
134
149
|
: {
|
|
135
150
|
type: "stdio",
|
|
136
151
|
command: mc.command,
|
|
137
152
|
args: mc.args,
|
|
153
|
+
// VS Code substitutes ${workspaceFolder} per window, so the server
|
|
154
|
+
// starts with the open project as cwd and scope resolution just works.
|
|
138
155
|
cwd: "${workspaceFolder}",
|
|
139
156
|
};
|
|
157
|
+
return { entry, durable: mc.durable };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Warn when init had to fall back to an npx-based server command. */
|
|
161
|
+
function warnNonDurable(durable: boolean): void {
|
|
162
|
+
if (durable) return;
|
|
163
|
+
console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
|
|
164
|
+
console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
|
|
165
|
+
}
|
|
140
166
|
|
|
141
|
-
|
|
167
|
+
/**
|
|
168
|
+
* Pure merge of one server entry into a parsed config doc.
|
|
169
|
+
* Returns "added" when the entry was written, "kept" when an entry already
|
|
170
|
+
* existed and force was not set. Mutates `doc`.
|
|
171
|
+
*/
|
|
172
|
+
export function mergeServerEntry(
|
|
173
|
+
doc: Record<string, unknown>,
|
|
174
|
+
sectionKey: string,
|
|
175
|
+
entry: Record<string, unknown>,
|
|
176
|
+
force: boolean,
|
|
177
|
+
): "added" | "kept" {
|
|
178
|
+
const section = ((doc[sectionKey] ??= {}) as Record<string, unknown>);
|
|
179
|
+
if (section["open-memex"] && !force) return "kept";
|
|
180
|
+
section["open-memex"] = entry;
|
|
181
|
+
return "added";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Read (or create) a JSON config file, merge the open-memex server entry, write
|
|
186
|
+
* it back. Existing files are merged, never clobbered; invalid JSON is left
|
|
187
|
+
* untouched. `mkdir` controls whether parent dirs are created (user-level
|
|
188
|
+
* configs) or expected to exist via the project root.
|
|
189
|
+
*/
|
|
190
|
+
function writeServerEntryFile(
|
|
191
|
+
file: string,
|
|
192
|
+
sectionKey: string,
|
|
193
|
+
entry: Record<string, unknown>,
|
|
194
|
+
force: boolean,
|
|
195
|
+
mkdir: boolean,
|
|
196
|
+
): string | null {
|
|
197
|
+
let doc: Record<string, unknown> = {};
|
|
198
|
+
if (fs.existsSync(file)) {
|
|
199
|
+
try {
|
|
200
|
+
doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
201
|
+
} catch {
|
|
202
|
+
console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const merged = mergeServerEntry(doc, sectionKey, entry, force);
|
|
207
|
+
if (merged === "kept") {
|
|
208
|
+
console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
|
|
209
|
+
return file;
|
|
210
|
+
}
|
|
211
|
+
if (mkdir) fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
142
212
|
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
143
213
|
console.log(` + ${file}`);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
214
|
+
return file;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** D45: `init --global` — one-time user-level wiring for VS Code / Cursor. */
|
|
218
|
+
function writeGlobalMcpJson(client: "vscode" | "cursor", force: boolean): string | null {
|
|
219
|
+
const file = userMcpConfigPath(client);
|
|
220
|
+
const sectionKey = client === "cursor" ? "mcpServers" : "servers";
|
|
221
|
+
const { entry, durable } = stdioServerEntry(client);
|
|
222
|
+
const written = writeServerEntryFile(file, sectionKey, entry, force, true);
|
|
223
|
+
if (written) {
|
|
224
|
+
warnNonDurable(durable);
|
|
225
|
+
const projFile = client === "cursor" ? ".cursor/mcp.json" : ".vscode/mcp.json";
|
|
226
|
+
console.log(` i user-level config — the open-memex MCP server now starts in every project.`);
|
|
227
|
+
console.log(` (a per-project ${projFile} still wins if a project defines its own)`);
|
|
228
|
+
}
|
|
229
|
+
return written;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Where this installed copy's opencode native plugin entry point lives. */
|
|
233
|
+
function opencodePluginUrl(): string {
|
|
234
|
+
// init.ts sits in <pkg>/src — the plugin entry is <pkg>/src/index.ts.
|
|
235
|
+
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
236
|
+
return pathToFileURL(path.join(pkgRoot, "src", "index.ts")).href;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* D46: opencode's user-level config. opencode reads `~/.config/opencode/`
|
|
241
|
+
* (`$XDG_CONFIG_HOME` when set) on every platform; a `plugin` entry there
|
|
242
|
+
* loads open-memex in every project with no per-project init.
|
|
243
|
+
*/
|
|
244
|
+
export function opencodeGlobalConfigPath(home: string = os.homedir()): string {
|
|
245
|
+
return path.join(opencodeConfigDir(home, process.env.XDG_CONFIG_HOME), "opencode.json");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Pure merge of the open-memex plugin URL into a parsed opencode config doc.
|
|
250
|
+
* Creates the `plugin` array when missing; never duplicates the URL.
|
|
251
|
+
* Returns "added" when the doc changed, "kept" when already present.
|
|
252
|
+
*/
|
|
253
|
+
export function mergePluginEntry(
|
|
254
|
+
doc: Record<string, unknown>,
|
|
255
|
+
url: string,
|
|
256
|
+
force: boolean,
|
|
257
|
+
): "added" | "kept" {
|
|
258
|
+
let plugins = doc["plugin"];
|
|
259
|
+
if (!Array.isArray(plugins)) {
|
|
260
|
+
plugins = [] as unknown[];
|
|
261
|
+
doc["plugin"] = plugins;
|
|
147
262
|
}
|
|
263
|
+
const list = plugins as unknown[];
|
|
264
|
+
if (list.includes(url) && !force) return "kept";
|
|
265
|
+
if (!list.includes(url)) list.push(url);
|
|
266
|
+
return "added";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* D46: wire the native plugin at the opencode user level — the one-time
|
|
271
|
+
* global setup. Merges into the existing config when it parses as JSON;
|
|
272
|
+
* a file with comments (JSONC) is left untouched with a manual hint instead
|
|
273
|
+
* of being clobbered.
|
|
274
|
+
*/
|
|
275
|
+
function writeOpencodeGlobalPlugin(force: boolean): string | null {
|
|
276
|
+
const dir = path.dirname(opencodeGlobalConfigPath());
|
|
277
|
+
const file =
|
|
278
|
+
["opencode.jsonc", "opencode.json"]
|
|
279
|
+
.map((f) => path.join(dir, f))
|
|
280
|
+
.find((f) => fs.existsSync(f)) ?? path.join(dir, "opencode.json");
|
|
281
|
+
const url = opencodePluginUrl();
|
|
282
|
+
let doc: Record<string, unknown> = {};
|
|
283
|
+
if (fs.existsSync(file)) {
|
|
284
|
+
try {
|
|
285
|
+
doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
286
|
+
} catch {
|
|
287
|
+
console.log(` ! ${file} has comments or invalid JSON — left untouched, fix it manually.`);
|
|
288
|
+
console.log(` To enable open-memex everywhere, add "plugin": ["${url}"] to it.`);
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (mergePluginEntry(doc, url, force) === "kept") {
|
|
293
|
+
console.log(` = ${file} already loads the open-memex plugin — left as is (use --force to overwrite)`);
|
|
294
|
+
return file;
|
|
295
|
+
}
|
|
296
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
297
|
+
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
298
|
+
console.log(` + ${file} (native plugin — works in every project, no per-project init needed)`);
|
|
148
299
|
return file;
|
|
149
300
|
}
|
|
150
301
|
|
|
@@ -252,6 +403,7 @@ function writeInstructions(
|
|
|
252
403
|
}
|
|
253
404
|
|
|
254
405
|
export const INIT_CLIENTS = ["vscode", "cursor", "opencode", "visualstudio"] as const;
|
|
406
|
+
export type InitClient = (typeof INIT_CLIENTS)[number];
|
|
255
407
|
|
|
256
408
|
/** Normalize --client values; accepts "visual-studio" as an alias. */
|
|
257
409
|
export function normalizeClient(c: string): string {
|
|
@@ -316,20 +468,152 @@ async function promptInstructionsScope(): Promise<"personal" | "project"> {
|
|
|
316
468
|
}
|
|
317
469
|
}
|
|
318
470
|
|
|
471
|
+
/** D45: ask whether the MCP server config should be project-level or user-level. */
|
|
472
|
+
async function promptConfigLevel(): Promise<boolean> {
|
|
473
|
+
console.log("Where should the MCP server config live?");
|
|
474
|
+
console.log(" 1) this project only — .vscode/mcp.json (or .cursor/mcp.json)");
|
|
475
|
+
console.log(" 2) user-level — all projects, init once (VS Code / Cursor)");
|
|
476
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
477
|
+
try {
|
|
478
|
+
const ans = (await rl.question("Choice [1]: ")).trim();
|
|
479
|
+
return ans === "2";
|
|
480
|
+
} finally {
|
|
481
|
+
rl.close();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** D46: injectable environment for editor detection (tests pass fakes). */
|
|
486
|
+
export interface DetectEnv {
|
|
487
|
+
pathEnv?: string;
|
|
488
|
+
home?: string;
|
|
489
|
+
platform?: NodeJS.Platform;
|
|
490
|
+
root?: string;
|
|
491
|
+
/** Overrides $XDG_CONFIG_HOME for the opencode global config dir. */
|
|
492
|
+
xdgConfigHome?: string;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function exists(p: string): boolean {
|
|
496
|
+
try {
|
|
497
|
+
return fs.existsSync(p);
|
|
498
|
+
} catch {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** True when `name` resolves on PATH (honors .cmd/.exe on Windows). */
|
|
504
|
+
function binOnPath(name: string, pathEnv: string, platform: NodeJS.Platform): boolean {
|
|
505
|
+
const names = platform === "win32" ? [`${name}.cmd`, `${name}.exe`, name] : [name];
|
|
506
|
+
return pathEnv
|
|
507
|
+
.split(path.delimiter)
|
|
508
|
+
.filter((d) => d && !d.includes("_npx"))
|
|
509
|
+
.some((d) => names.some((n) => exists(path.join(d, n))));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function opencodeConfigDir(home: string, xdg: string | undefined): string {
|
|
513
|
+
return path.join(xdg || path.join(home, ".config"), "opencode");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* D46: detect which supported editors are installed. Used when `init` runs
|
|
518
|
+
* without --client — one init wires every detected editor (user-level where
|
|
519
|
+
* the editor supports it). Visual Studio is included only when the project
|
|
520
|
+
* has a solution file, since VS config is solution-scoped by design.
|
|
521
|
+
*/
|
|
522
|
+
export function detectInstalledClients(env: DetectEnv = {}): InitClient[] {
|
|
523
|
+
const platform = env.platform ?? process.platform;
|
|
524
|
+
const pathEnv = env.pathEnv ?? process.env.PATH ?? "";
|
|
525
|
+
const home = env.home ?? os.homedir();
|
|
526
|
+
const found: InitClient[] = [];
|
|
527
|
+
// VS Code: bin on PATH, well-known install location, or an existing
|
|
528
|
+
// user-level MCP config (a previous init counts as installed).
|
|
529
|
+
const vscodeInstallPaths =
|
|
530
|
+
platform === "win32"
|
|
531
|
+
? [path.join(home, "AppData", "Local", "Programs", "Microsoft VS Code", "Code.exe")]
|
|
532
|
+
: platform === "darwin"
|
|
533
|
+
? ["/Applications/Visual Studio Code.app"]
|
|
534
|
+
: ["/usr/bin/code", "/usr/share/code/bin/code", "/snap/bin/code"];
|
|
535
|
+
if (
|
|
536
|
+
binOnPath("code", pathEnv, platform) ||
|
|
537
|
+
vscodeInstallPaths.some(exists) ||
|
|
538
|
+
exists(userMcpConfigPath("vscode", platform, home))
|
|
539
|
+
) {
|
|
540
|
+
found.push("vscode");
|
|
541
|
+
}
|
|
542
|
+
// Cursor: bin on PATH or its user config dir exists.
|
|
543
|
+
if (binOnPath("cursor", pathEnv, platform) || exists(path.join(home, ".cursor"))) {
|
|
544
|
+
found.push("cursor");
|
|
545
|
+
}
|
|
546
|
+
// opencode: bin on PATH or its global config dir exists.
|
|
547
|
+
const xdg = env.xdgConfigHome ?? process.env.XDG_CONFIG_HOME;
|
|
548
|
+
if (binOnPath("opencode", pathEnv, platform) || exists(opencodeConfigDir(home, xdg))) {
|
|
549
|
+
found.push("opencode");
|
|
550
|
+
}
|
|
551
|
+
// Visual Studio: solution-scoped — only when the project has a .sln.
|
|
552
|
+
let hasSln = false;
|
|
553
|
+
try {
|
|
554
|
+
hasSln = fs.readdirSync(env.root ?? projectRoot()).some((f) => f.toLowerCase().endsWith(".sln"));
|
|
555
|
+
} catch {
|
|
556
|
+
hasSln = false;
|
|
557
|
+
}
|
|
558
|
+
if (hasSln) found.push("visualstudio");
|
|
559
|
+
return found;
|
|
560
|
+
}
|
|
561
|
+
|
|
319
562
|
export async function initProject(opts: {
|
|
320
563
|
client?: string;
|
|
321
564
|
force: boolean;
|
|
322
565
|
yes: boolean;
|
|
323
566
|
instructions?: string;
|
|
567
|
+
/** D45: write the MCP server entry to the editor's user-level config. */
|
|
568
|
+
global?: boolean;
|
|
324
569
|
}): Promise<void> {
|
|
325
570
|
const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
326
|
-
|
|
327
|
-
if (
|
|
571
|
+
const explicit = normalizeClient(opts.client ?? "");
|
|
572
|
+
if (explicit && !(INIT_CLIENTS as readonly string[]).includes(explicit)) {
|
|
328
573
|
console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
|
|
329
574
|
process.exit(1);
|
|
330
575
|
}
|
|
331
|
-
|
|
332
|
-
|
|
576
|
+
// D46: no --client → auto-detect installed editors and wire them all
|
|
577
|
+
// (user-level where the editor supports it — init once). An explicit
|
|
578
|
+
// --client keeps the old single-editor behavior.
|
|
579
|
+
let clients: InitClient[];
|
|
580
|
+
let autoGlobal = false;
|
|
581
|
+
if (explicit) {
|
|
582
|
+
clients = [explicit as InitClient];
|
|
583
|
+
} else if (interactive) {
|
|
584
|
+
const detected = detectInstalledClients();
|
|
585
|
+
if (detected.length === 0) {
|
|
586
|
+
console.log(" - no supported editors detected — editor setup skipped");
|
|
587
|
+
clients = [];
|
|
588
|
+
} else {
|
|
589
|
+
console.log(`Detected editors: ${detected.join(", ")}`);
|
|
590
|
+
const all = await askBool(
|
|
591
|
+
"Wire up open-memex in all of them (one-time, user-level where supported)?",
|
|
592
|
+
true,
|
|
593
|
+
);
|
|
594
|
+
if (all) {
|
|
595
|
+
clients = detected;
|
|
596
|
+
autoGlobal = true;
|
|
597
|
+
} else {
|
|
598
|
+
const one = await promptClient();
|
|
599
|
+
clients = one ? [one as InitClient] : [];
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
} else {
|
|
603
|
+
clients = detectInstalledClients();
|
|
604
|
+
autoGlobal = true;
|
|
605
|
+
if (clients.length === 0) {
|
|
606
|
+
console.log(" - no supported editors detected — editor setup skipped");
|
|
607
|
+
} else {
|
|
608
|
+
console.log(`Detected editors: ${clients.join(", ")} — wiring all (use --client to pick one)`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
let global = !!opts.global || autoGlobal;
|
|
612
|
+
// The project-vs-user-level choice only applies to an explicit single
|
|
613
|
+
// client; auto mode is user-level by design (init once).
|
|
614
|
+
if (explicit && !global && interactive && (clients[0] === "vscode" || clients[0] === "cursor")) {
|
|
615
|
+
global = await promptConfigLevel();
|
|
616
|
+
}
|
|
333
617
|
let scope: "personal" | "project" = "personal";
|
|
334
618
|
if (opts.instructions) {
|
|
335
619
|
if (opts.instructions !== "personal" && opts.instructions !== "project") {
|
|
@@ -365,15 +649,31 @@ export async function initProject(opts: {
|
|
|
365
649
|
}
|
|
366
650
|
const root = projectRoot();
|
|
367
651
|
console.log(`open-memex init — project root: ${root}`);
|
|
368
|
-
|
|
369
|
-
|
|
652
|
+
for (const client of clients) {
|
|
653
|
+
if (client === "vscode" || client === "cursor") {
|
|
654
|
+
if (global) writeGlobalMcpJson(client, opts.force);
|
|
655
|
+
else writeMcpJson(root, client, opts.force);
|
|
656
|
+
} else if (client === "opencode") {
|
|
657
|
+
// D46: the native plugin is the one-time global setup; without --global
|
|
658
|
+
// (explicit single-client mode) keep the per-project plain-MCP config.
|
|
659
|
+
if (global) writeOpencodeGlobalPlugin(opts.force);
|
|
660
|
+
else writeMcpJson(root, client, opts.force);
|
|
661
|
+
} else if (client === "visualstudio") {
|
|
662
|
+
if (explicit && global) {
|
|
663
|
+
console.log(` - visualstudio: --global not supported — VS uses solution-level .mcp.json by design.`);
|
|
664
|
+
} else {
|
|
665
|
+
writeMcpJson(root, client, opts.force);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (clients.length === 0) {
|
|
670
|
+
console.log(" - editor setup skipped");
|
|
671
|
+
} else if (clients.some((c) => c !== "opencode")) {
|
|
370
672
|
// copilot-instructions.md is VS Code/Cursor-shaped; opencode as a plain MCP
|
|
371
673
|
// consumer already gets the guidance from the tool descriptions (D16).
|
|
372
674
|
// D22: personal scope (default) writes to the Copilot user-level location
|
|
373
675
|
// so the repo stays clean for teammates without open-memex.
|
|
374
|
-
|
|
375
|
-
} else {
|
|
376
|
-
console.log(" - editor setup skipped");
|
|
676
|
+
writeInstructions(root, scope, clients.find((c) => c !== "opencode")!);
|
|
377
677
|
}
|
|
378
678
|
console.log(`\nDone. Reload your editor window to start the open-memex MCP server.`);
|
|
379
679
|
}
|
package/src/store/v2migrate.ts
CHANGED
|
@@ -118,23 +118,48 @@ export interface MigrateV2Result {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* backup path, or null when no legacy dir exists.
|
|
121
|
+
* Locate a legacy `my-o-memory` data root next to the current root.
|
|
122
|
+
* Pure lookup — no disk writes.
|
|
124
123
|
*/
|
|
125
|
-
function
|
|
124
|
+
export function findLegacyDataRoot(): string | null {
|
|
126
125
|
const { root } = paths();
|
|
127
126
|
const legacy = path.join(path.dirname(root), "my-o-memory");
|
|
128
127
|
if (!fs.existsSync(legacy) || !fs.statSync(legacy).isDirectory()) return null;
|
|
128
|
+
return legacy;
|
|
129
|
+
}
|
|
129
130
|
|
|
131
|
+
/** Dated backup path for a legacy data root. Pure — no disk writes. */
|
|
132
|
+
export function legacyBackupPath(legacy: string): string {
|
|
130
133
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
return `${legacy}.backup-${stamp}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Merge a legacy `my-o-memory` data root into the current `open-memex` root.
|
|
139
|
+
* The old dir is renamed to a dated backup — never deleted.
|
|
140
|
+
* Failures throw an actionable Error (no raw syscall dump): on Windows the
|
|
141
|
+
* backup rename typically fails with EPERM when another program holds the
|
|
142
|
+
* folder open.
|
|
143
|
+
*/
|
|
144
|
+
function mergeLegacyDataRoot(legacy: string, backup: string): void {
|
|
145
|
+
const { memories } = paths();
|
|
146
|
+
const fail = (where: string, err: unknown): never => {
|
|
147
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
148
|
+
throw new Error(
|
|
149
|
+
`could not ${where} (${legacy})` +
|
|
150
|
+
(code ? ` [${code}]` : "") +
|
|
151
|
+
`. Another program may be holding the folder open (e.g. a running MCP server, editor, or antivirus). ` +
|
|
152
|
+
`Your memories are safe — nothing was deleted. Close the program and re-run ` +
|
|
153
|
+
`\`open-memex migrate --to-v2\`, or rename the folder to ${backup} yourself and re-run.`,
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
const legacyMem = path.join(legacy, "memories");
|
|
157
|
+
if (fs.existsSync(legacyMem)) {
|
|
158
|
+
fs.mkdirSync(memories, { recursive: true });
|
|
159
|
+
for (const entry of fs.readdirSync(legacyMem)) {
|
|
160
|
+
const src = path.join(legacyMem, entry);
|
|
161
|
+
const dst = path.join(memories, entry);
|
|
162
|
+
try {
|
|
138
163
|
if (!fs.existsSync(dst)) {
|
|
139
164
|
fs.renameSync(src, dst);
|
|
140
165
|
} else {
|
|
@@ -145,46 +170,75 @@ function migrateLegacyDataRoot(dryRun: boolean): string | null {
|
|
|
145
170
|
if (!fs.existsSync(d)) fs.renameSync(s, d);
|
|
146
171
|
}
|
|
147
172
|
}
|
|
173
|
+
} catch (err) {
|
|
174
|
+
fail(`move memories from the legacy data dir`, err);
|
|
148
175
|
}
|
|
149
176
|
}
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
150
179
|
fs.renameSync(legacy, backup);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
fail(`back up the legacy data dir`, err);
|
|
151
182
|
}
|
|
152
|
-
return backup;
|
|
153
183
|
}
|
|
154
184
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
result.legacyBackup = migrateLegacyDataRoot(opts.dryRun);
|
|
166
|
-
|
|
167
|
-
if (!fs.existsSync(memoriesRoot)) return result;
|
|
168
|
-
for (const entry of fs.readdirSync(memoriesRoot, { withFileTypes: true })) {
|
|
185
|
+
/** Plan v1 → v2 conversion for every `.md` file under `dir`. Read-only. */
|
|
186
|
+
function planDir(
|
|
187
|
+
dir: string,
|
|
188
|
+
memoriesRoot: string,
|
|
189
|
+
result: MigrateV2Result,
|
|
190
|
+
): void {
|
|
191
|
+
if (!fs.existsSync(dir)) return;
|
|
192
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
169
193
|
if (!entry.isDirectory()) continue;
|
|
170
|
-
const
|
|
171
|
-
for (const name of fs.readdirSync(
|
|
194
|
+
const sub = path.join(dir, entry.name);
|
|
195
|
+
for (const name of fs.readdirSync(sub)) {
|
|
172
196
|
if (!name.endsWith(".md")) continue;
|
|
173
|
-
const filePath = path.join(
|
|
197
|
+
const filePath = path.join(sub, name);
|
|
174
198
|
result.scanned++;
|
|
175
199
|
const plan = planConversion(filePath, memoriesRoot);
|
|
176
200
|
if (!plan) {
|
|
177
201
|
result.skippedV2++;
|
|
178
202
|
continue;
|
|
179
203
|
}
|
|
180
|
-
if (!opts.dryRun) {
|
|
181
|
-
fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
|
|
182
|
-
fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
|
|
183
|
-
if (plan.toPath !== plan.fromPath) fs.unlinkSync(plan.fromPath);
|
|
184
|
-
}
|
|
185
204
|
result.converted++;
|
|
186
205
|
result.plans.push(plan);
|
|
187
206
|
}
|
|
188
207
|
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Apply conversion plans to disk (real run only — never in dry-run). */
|
|
211
|
+
function applyPlans(result: MigrateV2Result): void {
|
|
212
|
+
for (const plan of result.plans) {
|
|
213
|
+
fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
|
|
214
|
+
fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
|
|
215
|
+
if (plan.toPath !== plan.fromPath) fs.unlinkSync(plan.fromPath);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function migrateV2(opts: { dryRun: boolean }): MigrateV2Result {
|
|
220
|
+
const { memories: memoriesRoot } = paths();
|
|
221
|
+
const result: MigrateV2Result = {
|
|
222
|
+
scanned: 0,
|
|
223
|
+
converted: 0,
|
|
224
|
+
skippedV2: 0,
|
|
225
|
+
plans: [],
|
|
226
|
+
legacyBackup: null,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const legacy = findLegacyDataRoot();
|
|
230
|
+
if (legacy) {
|
|
231
|
+
result.legacyBackup = legacyBackupPath(legacy);
|
|
232
|
+
if (opts.dryRun) {
|
|
233
|
+
// D44: dry-run previews the legacy files in place — nothing is moved,
|
|
234
|
+
// so the preview actually shows what would convert (issue #7).
|
|
235
|
+
planDir(path.join(legacy, "memories"), memoriesRoot, result);
|
|
236
|
+
} else {
|
|
237
|
+
mergeLegacyDataRoot(legacy, result.legacyBackup);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
planDir(memoriesRoot, memoriesRoot, result);
|
|
242
|
+
if (!opts.dryRun) applyPlans(result);
|
|
189
243
|
return result;
|
|
190
244
|
}
|