open-memex 0.4.0 → 0.4.1
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/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/dist/cli.js +27 -6
- package/dist/store/v2migrate.js +83 -34
- package/docs/V2-DESIGN.md +15 -0
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +49 -1
- package/src/cli.ts +26 -6
- package/src/store/v2migrate.ts +88 -34
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# open-memex
|
|
2
2
|
|
|
3
|
+
> A shared organizational memory layer that helps people and AI capture, retain, and reuse institutional knowledge.
|
|
4
|
+
|
|
3
5
|
[](https://www.npmjs.com/package/open-memex)
|
|
4
6
|
[](./LICENSE)
|
|
5
7
|
|
package/README.zh-CN.md
CHANGED
package/dist/cli.js
CHANGED
|
@@ -223,11 +223,15 @@ Usage: open-memex migrate [--from <key>] [--to <key>] [--dry-run] [--on-conflict
|
|
|
223
223
|
|
|
224
224
|
Flags:
|
|
225
225
|
--from / --to scope keys (default: current project → personal)
|
|
226
|
-
--dry-run preview without moving anything
|
|
226
|
+
--dry-run preview without moving anything (--to-v2 previews the legacy
|
|
227
|
+
files in place, including per-file conversion plans)
|
|
227
228
|
--on-conflict newer (default), overwrite, or skip
|
|
228
229
|
--to-v2 convert a legacy my-o-memory data dir to the v2 layout
|
|
229
230
|
|
|
230
231
|
Always preview with --dry-run first; nothing moves without confirmation.
|
|
232
|
+
On Windows, if another program holds the legacy folder open, the backup
|
|
233
|
+
rename fails with an actionable message instead of a stack trace — close
|
|
234
|
+
the program and re-run.
|
|
231
235
|
|
|
232
236
|
Examples:
|
|
233
237
|
open-memex migrate --dry-run
|
|
@@ -376,14 +380,21 @@ function parseFlags(argv) {
|
|
|
376
380
|
for (let i = 0; i < argv.length; i++) {
|
|
377
381
|
const a = argv[i];
|
|
378
382
|
if (a.startsWith("--")) {
|
|
379
|
-
const
|
|
383
|
+
const raw = a.slice(2);
|
|
384
|
+
const eq = raw.indexOf("=");
|
|
385
|
+
if (eq >= 0) {
|
|
386
|
+
// D44: accept --key=value as well as --key value (issue #7 — the
|
|
387
|
+
// = form was silently misparsed before, dropping the flag).
|
|
388
|
+
out[raw.slice(0, eq)] = raw.slice(eq + 1);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
380
391
|
const val = argv[i + 1];
|
|
381
392
|
if (val !== undefined && !val.startsWith("--")) {
|
|
382
|
-
out[
|
|
393
|
+
out[raw] = val;
|
|
383
394
|
i++;
|
|
384
395
|
}
|
|
385
396
|
else {
|
|
386
|
-
out[
|
|
397
|
+
out[raw] = "true";
|
|
387
398
|
}
|
|
388
399
|
}
|
|
389
400
|
}
|
|
@@ -486,7 +497,15 @@ async function main() {
|
|
|
486
497
|
if (cmd === "migrate" && rest.includes("--to-v2")) {
|
|
487
498
|
const flags = parseFlags(rest);
|
|
488
499
|
const dryRun = flags["dry-run"] === "true";
|
|
489
|
-
|
|
500
|
+
let stats;
|
|
501
|
+
try {
|
|
502
|
+
stats = migrateV2({ dryRun });
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
// D44: actionable message, not a raw syscall stack (issue #7).
|
|
506
|
+
console.error(`Error: ${err.message}`);
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
490
509
|
console.log(`${dryRun ? "DRY RUN: " : ""}scanned ${stats.scanned} files: ` +
|
|
491
510
|
`${stats.converted} to convert, ${stats.skippedV2} already v2`);
|
|
492
511
|
for (const p of stats.plans) {
|
|
@@ -496,7 +515,9 @@ async function main() {
|
|
|
496
515
|
console.log(` - ${c}`);
|
|
497
516
|
}
|
|
498
517
|
if (stats.legacyBackup) {
|
|
499
|
-
console.log(
|
|
518
|
+
console.log(dryRun
|
|
519
|
+
? `\nDRY RUN: legacy my-o-memory data dir found — it would be merged and backed up at:\n ${stats.legacyBackup}`
|
|
520
|
+
: `\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`);
|
|
500
521
|
}
|
|
501
522
|
if (!dryRun && stats.converted > 0) {
|
|
502
523
|
console.log(`\nindex schema will rebuild automatically on next run; ` +
|
package/dist/store/v2migrate.js
CHANGED
|
@@ -83,23 +83,45 @@ export function planConversion(filePath, memoriesRoot) {
|
|
|
83
83
|
return { fm, body, fromPath: filePath, toPath, changes };
|
|
84
84
|
}
|
|
85
85
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* backup path, or null when no legacy dir exists.
|
|
86
|
+
* Locate a legacy `my-o-memory` data root next to the current root.
|
|
87
|
+
* Pure lookup — no disk writes.
|
|
89
88
|
*/
|
|
90
|
-
function
|
|
89
|
+
export function findLegacyDataRoot() {
|
|
91
90
|
const { root } = paths();
|
|
92
91
|
const legacy = path.join(path.dirname(root), "my-o-memory");
|
|
93
92
|
if (!fs.existsSync(legacy) || !fs.statSync(legacy).isDirectory())
|
|
94
93
|
return null;
|
|
94
|
+
return legacy;
|
|
95
|
+
}
|
|
96
|
+
/** Dated backup path for a legacy data root. Pure — no disk writes. */
|
|
97
|
+
export function legacyBackupPath(legacy) {
|
|
95
98
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
return `${legacy}.backup-${stamp}`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Merge a legacy `my-o-memory` data root into the current `open-memex` root.
|
|
103
|
+
* The old dir is renamed to a dated backup — never deleted.
|
|
104
|
+
* Failures throw an actionable Error (no raw syscall dump): on Windows the
|
|
105
|
+
* backup rename typically fails with EPERM when another program holds the
|
|
106
|
+
* folder open.
|
|
107
|
+
*/
|
|
108
|
+
function mergeLegacyDataRoot(legacy, backup) {
|
|
109
|
+
const { memories } = paths();
|
|
110
|
+
const fail = (where, err) => {
|
|
111
|
+
const code = err?.code;
|
|
112
|
+
throw new Error(`could not ${where} (${legacy})` +
|
|
113
|
+
(code ? ` [${code}]` : "") +
|
|
114
|
+
`. Another program may be holding the folder open (e.g. a running MCP server, editor, or antivirus). ` +
|
|
115
|
+
`Your memories are safe — nothing was deleted. Close the program and re-run ` +
|
|
116
|
+
`\`open-memex migrate --to-v2\`, or rename the folder to ${backup} yourself and re-run.`);
|
|
117
|
+
};
|
|
118
|
+
const legacyMem = path.join(legacy, "memories");
|
|
119
|
+
if (fs.existsSync(legacyMem)) {
|
|
120
|
+
fs.mkdirSync(memories, { recursive: true });
|
|
121
|
+
for (const entry of fs.readdirSync(legacyMem)) {
|
|
122
|
+
const src = path.join(legacyMem, entry);
|
|
123
|
+
const dst = path.join(memories, entry);
|
|
124
|
+
try {
|
|
103
125
|
if (!fs.existsSync(dst)) {
|
|
104
126
|
fs.renameSync(src, dst);
|
|
105
127
|
}
|
|
@@ -113,46 +135,73 @@ function migrateLegacyDataRoot(dryRun) {
|
|
|
113
135
|
}
|
|
114
136
|
}
|
|
115
137
|
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
fail(`move memories from the legacy data dir`, err);
|
|
140
|
+
}
|
|
116
141
|
}
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
117
144
|
fs.renameSync(legacy, backup);
|
|
118
145
|
}
|
|
119
|
-
|
|
146
|
+
catch (err) {
|
|
147
|
+
fail(`back up the legacy data dir`, err);
|
|
148
|
+
}
|
|
120
149
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
skippedV2: 0,
|
|
127
|
-
plans: [],
|
|
128
|
-
legacyBackup: null,
|
|
129
|
-
};
|
|
130
|
-
result.legacyBackup = migrateLegacyDataRoot(opts.dryRun);
|
|
131
|
-
if (!fs.existsSync(memoriesRoot))
|
|
132
|
-
return result;
|
|
133
|
-
for (const entry of fs.readdirSync(memoriesRoot, { withFileTypes: true })) {
|
|
150
|
+
/** Plan v1 → v2 conversion for every `.md` file under `dir`. Read-only. */
|
|
151
|
+
function planDir(dir, memoriesRoot, result) {
|
|
152
|
+
if (!fs.existsSync(dir))
|
|
153
|
+
return;
|
|
154
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
134
155
|
if (!entry.isDirectory())
|
|
135
156
|
continue;
|
|
136
|
-
const
|
|
137
|
-
for (const name of fs.readdirSync(
|
|
157
|
+
const sub = path.join(dir, entry.name);
|
|
158
|
+
for (const name of fs.readdirSync(sub)) {
|
|
138
159
|
if (!name.endsWith(".md"))
|
|
139
160
|
continue;
|
|
140
|
-
const filePath = path.join(
|
|
161
|
+
const filePath = path.join(sub, name);
|
|
141
162
|
result.scanned++;
|
|
142
163
|
const plan = planConversion(filePath, memoriesRoot);
|
|
143
164
|
if (!plan) {
|
|
144
165
|
result.skippedV2++;
|
|
145
166
|
continue;
|
|
146
167
|
}
|
|
147
|
-
if (!opts.dryRun) {
|
|
148
|
-
fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
|
|
149
|
-
fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
|
|
150
|
-
if (plan.toPath !== plan.fromPath)
|
|
151
|
-
fs.unlinkSync(plan.fromPath);
|
|
152
|
-
}
|
|
153
168
|
result.converted++;
|
|
154
169
|
result.plans.push(plan);
|
|
155
170
|
}
|
|
156
171
|
}
|
|
172
|
+
}
|
|
173
|
+
/** Apply conversion plans to disk (real run only — never in dry-run). */
|
|
174
|
+
function applyPlans(result) {
|
|
175
|
+
for (const plan of result.plans) {
|
|
176
|
+
fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
|
|
177
|
+
fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
|
|
178
|
+
if (plan.toPath !== plan.fromPath)
|
|
179
|
+
fs.unlinkSync(plan.fromPath);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export function migrateV2(opts) {
|
|
183
|
+
const { memories: memoriesRoot } = paths();
|
|
184
|
+
const result = {
|
|
185
|
+
scanned: 0,
|
|
186
|
+
converted: 0,
|
|
187
|
+
skippedV2: 0,
|
|
188
|
+
plans: [],
|
|
189
|
+
legacyBackup: null,
|
|
190
|
+
};
|
|
191
|
+
const legacy = findLegacyDataRoot();
|
|
192
|
+
if (legacy) {
|
|
193
|
+
result.legacyBackup = legacyBackupPath(legacy);
|
|
194
|
+
if (opts.dryRun) {
|
|
195
|
+
// D44: dry-run previews the legacy files in place — nothing is moved,
|
|
196
|
+
// so the preview actually shows what would convert (issue #7).
|
|
197
|
+
planDir(path.join(legacy, "memories"), memoriesRoot, result);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
mergeLegacyDataRoot(legacy, result.legacyBackup);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
planDir(memoriesRoot, memoriesRoot, result);
|
|
204
|
+
if (!opts.dryRun)
|
|
205
|
+
applyPlans(result);
|
|
157
206
|
return result;
|
|
158
207
|
}
|
package/docs/V2-DESIGN.md
CHANGED
|
@@ -867,6 +867,21 @@ requirement: personal data never touches third-party services). Benchmarks to tr
|
|
|
867
867
|
MCP handshake instructions or the `init`-written instruction files — but
|
|
868
868
|
opencode reads AGENTS.md natively, so the distilled snippet teaches the
|
|
869
869
|
checkpoint habit wherever it lands. Approved 2026-09-29.*
|
|
870
|
+
- **D44** — `migrate --to-v2` hotfix (issue #7, 0.4.1): (1) `--dry-run` now
|
|
871
|
+
previews the legacy `my-o-memory` files in place — per-file conversion plans
|
|
872
|
+
without moving anything (previously it scanned only the new, still-empty
|
|
873
|
+
root and always reported "0 files", making the preview useless); (2) dry-run
|
|
874
|
+
no longer prints the false "legacy data dir merged" line; (3) the Windows
|
|
875
|
+
EPERM on the legacy-dir backup rename is caught and rethrown as an actionable
|
|
876
|
+
message (close the program holding the folder — e.g. a running MCP server —
|
|
877
|
+
and re-run; nothing was deleted), and the CLI prints it as `Error: …` with
|
|
878
|
+
exit 1 instead of a raw syscall stack; (4) `parseFlags` accepts `--key=value`
|
|
879
|
+
in addition to `--key value` (the `=` form was silently misparsed before,
|
|
880
|
+
dropping the flag — which can turn a `--dry-run` into a real run).
|
|
881
|
+
*Rationale: a preview that shows nothing is worse than no preview — the user
|
|
882
|
+
cannot confirm what the real run will do; and a destructive-path failure must
|
|
883
|
+
speak in user terms. Shipped as 0.4.1 hotfix on the stable line. Approved
|
|
884
|
+
2026-09-29.*
|
|
870
885
|
- **D41** — The type taxonomy is reconciled to 11 types with one-line definitions
|
|
871
886
|
(§3.1): `fact` `preference` `decision` `constraint` `todo` `knowledge` `howto`
|
|
872
887
|
`gotcha` `lesson` `observation` `reference`. Merged away: `warning`→`gotcha`,
|
package/package.json
CHANGED
package/scripts/smoke-pure.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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";
|
|
@@ -314,6 +314,54 @@ const plan2 = planConversion(v1path, memRoot);
|
|
|
314
314
|
ok("v2 on disk → null plan", plan2 === null);
|
|
315
315
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
316
316
|
|
|
317
|
+
console.log("== migrateV2 dry-run previews legacy files (issue #7) ==");
|
|
318
|
+
// legacy dir sits next to the new root (sibling), as on a real v1 upgrade
|
|
319
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "v2hotfix-"));
|
|
320
|
+
const newRoot = path.join(tmpHome, "open-memex");
|
|
321
|
+
const legacyRoot = path.join(tmpHome, "my-o-memory");
|
|
322
|
+
const legacyMem = path.join(legacyRoot, "memories", "user");
|
|
323
|
+
fs.mkdirSync(legacyMem, { recursive: true });
|
|
324
|
+
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`;
|
|
325
|
+
fs.writeFileSync(path.join(legacyMem, "01HOTFIX1.md"), v1legacy, "utf8");
|
|
326
|
+
process.env.MY_O_MEMORY_HOME = newRoot;
|
|
327
|
+
|
|
328
|
+
const dry = migrateV2({ dryRun: true });
|
|
329
|
+
ok("dry-run scans the legacy file", dry.scanned === 1 && dry.plans.length === 1, `scanned=${dry.scanned}`);
|
|
330
|
+
ok("dry-run yields a conversion plan", dry.plans.length === 1 && dry.plans[0].toPath.startsWith(newRoot), dry.plans[0]?.toPath);
|
|
331
|
+
ok("dry-run reports legacy backup target", typeof dry.legacyBackup === "string" && dry.legacyBackup.includes("backup-"), dry.legacyBackup);
|
|
332
|
+
ok("dry-run moves nothing", fs.existsSync(path.join(legacyMem, "01HOTFIX1.md")) && !fs.existsSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md")));
|
|
333
|
+
ok("dry-run does not rename legacy dir", fs.existsSync(legacyRoot) && !fs.existsSync(dry.legacyBackup!));
|
|
334
|
+
|
|
335
|
+
const real = migrateV2({ dryRun: false });
|
|
336
|
+
ok("real run converts", real.converted === 1, `converted=${real.converted}`);
|
|
337
|
+
ok("real run writes v2 file to new root", fs.existsSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md")));
|
|
338
|
+
ok("real run backs up legacy dir", !fs.existsSync(legacyRoot) && fs.existsSync(real.legacyBackup!));
|
|
339
|
+
const backContent = fs.readFileSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md"), "utf8");
|
|
340
|
+
ok("converted file has schema_version 2", backContent.includes("schema_version: 2"));
|
|
341
|
+
|
|
342
|
+
// backup rename failure → actionable Error, exit-1-worthy, no raw stack
|
|
343
|
+
// (same process/root: paths() is cached per process)
|
|
344
|
+
const legacyRoot2 = path.join(tmpHome, "my-o-memory");
|
|
345
|
+
const legacyMem2 = path.join(legacyRoot2, "memories", "user");
|
|
346
|
+
fs.mkdirSync(legacyMem2, { recursive: true });
|
|
347
|
+
fs.writeFileSync(path.join(legacyMem2, "01HOTFIX2.md"), v1legacy, "utf8");
|
|
348
|
+
// block the rename: pre-create today's dated backup as a non-empty dir
|
|
349
|
+
const blocked = path.join(tmpHome, "my-o-memory.backup-" + new Date().toISOString().slice(0, 10));
|
|
350
|
+
fs.mkdirSync(blocked, { recursive: true });
|
|
351
|
+
fs.writeFileSync(path.join(blocked, "sentinel"), "x", "utf8");
|
|
352
|
+
let boom: unknown = null;
|
|
353
|
+
try {
|
|
354
|
+
migrateV2({ dryRun: false });
|
|
355
|
+
} catch (e) {
|
|
356
|
+
boom = e;
|
|
357
|
+
}
|
|
358
|
+
ok("backup failure throws", boom instanceof Error, String(boom));
|
|
359
|
+
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) : "");
|
|
360
|
+
ok("failed backup keeps moved files safe in new root", fs.existsSync(path.join(newRoot, "memories", "user", "01HOTFIX2.md")));
|
|
361
|
+
ok("failed backup leaves legacy dir for retry", fs.existsSync(legacyRoot2));
|
|
362
|
+
delete process.env.MY_O_MEMORY_HOME;
|
|
363
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
364
|
+
|
|
317
365
|
console.log("== lifecycle pure: contentHash / similarity ==");
|
|
318
366
|
ok("hash deterministic + whitespace-insensitive", contentHash("hello world\n") === contentHash("hello world"));
|
|
319
367
|
ok("hash differs on content", contentHash("hello world") !== contentHash("hello mars"));
|
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
|
|
@@ -412,13 +416,20 @@ function parseFlags(argv: string[]): Record<string, string> {
|
|
|
412
416
|
for (let i = 0; i < argv.length; i++) {
|
|
413
417
|
const a = argv[i]!;
|
|
414
418
|
if (a.startsWith("--")) {
|
|
415
|
-
const
|
|
419
|
+
const raw = a.slice(2);
|
|
420
|
+
const eq = raw.indexOf("=");
|
|
421
|
+
if (eq >= 0) {
|
|
422
|
+
// D44: accept --key=value as well as --key value (issue #7 — the
|
|
423
|
+
// = form was silently misparsed before, dropping the flag).
|
|
424
|
+
out[raw.slice(0, eq)] = raw.slice(eq + 1);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
416
427
|
const val = argv[i + 1];
|
|
417
428
|
if (val !== undefined && !val.startsWith("--")) {
|
|
418
|
-
out[
|
|
429
|
+
out[raw] = val;
|
|
419
430
|
i++;
|
|
420
431
|
} else {
|
|
421
|
-
out[
|
|
432
|
+
out[raw] = "true";
|
|
422
433
|
}
|
|
423
434
|
}
|
|
424
435
|
}
|
|
@@ -548,7 +559,14 @@ async function main() {
|
|
|
548
559
|
if (cmd === "migrate" && rest.includes("--to-v2")) {
|
|
549
560
|
const flags = parseFlags(rest);
|
|
550
561
|
const dryRun = flags["dry-run"] === "true";
|
|
551
|
-
|
|
562
|
+
let stats;
|
|
563
|
+
try {
|
|
564
|
+
stats = migrateV2({ dryRun });
|
|
565
|
+
} catch (err) {
|
|
566
|
+
// D44: actionable message, not a raw syscall stack (issue #7).
|
|
567
|
+
console.error(`Error: ${(err as Error).message}`);
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
552
570
|
console.log(
|
|
553
571
|
`${dryRun ? "DRY RUN: " : ""}scanned ${stats.scanned} files: ` +
|
|
554
572
|
`${stats.converted} to convert, ${stats.skippedV2} already v2`,
|
|
@@ -562,7 +580,9 @@ async function main() {
|
|
|
562
580
|
}
|
|
563
581
|
if (stats.legacyBackup) {
|
|
564
582
|
console.log(
|
|
565
|
-
|
|
583
|
+
dryRun
|
|
584
|
+
? `\nDRY RUN: legacy my-o-memory data dir found — it would be merged and backed up at:\n ${stats.legacyBackup}`
|
|
585
|
+
: `\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`,
|
|
566
586
|
);
|
|
567
587
|
}
|
|
568
588
|
if (!dryRun && stats.converted > 0) {
|
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
|
}
|