@polderlabs/bizar 4.2.0 → 4.2.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.
@@ -327,3 +327,65 @@ export function lsRemote(repoDir, remoteName, { timeoutMs = 5000 } = {}) {
327
327
  return '';
328
328
  }
329
329
  }
330
+
331
+ /**
332
+ * Ensure the current branch tracks `<remote>/<branch>`. Idempotent.
333
+ *
334
+ * Some workflows create a managed vault before `origin` exists (or before
335
+ * the user pushes for the first time). Without an upstream, `git pull`
336
+ * fails with "There is no tracking information for the current branch."
337
+ * This helper detects that case and sets the upstream, or returns a
338
+ * structured result if it can't.
339
+ *
340
+ * Behaviour:
341
+ * - Upstream already set to <remote>/<branch> → { ok: true, action: 'unchanged' }
342
+ * - Upstream missing → set it, return { ok: true, action: 'set' }
343
+ * - Upstream set to a DIFFERENT remote/branch → set it to the requested one,
344
+ * return { ok: true, action: 'set' }
345
+ * - <remote> does not exist → { ok: false, error: 'remote "<remote>" does not exist' }
346
+ * - git not installed → { ok: false, error: 'git not installed' }
347
+ *
348
+ * @param {string} cwd — git working directory
349
+ * @param {string} branch — local branch name (e.g. 'main')
350
+ * @param {string} [remote='origin'] — remote name
351
+ * @returns {{ ok: boolean, action?: 'unchanged'|'set', error?: string }}
352
+ */
353
+ export function ensureUpstream(cwd, branch, remote = 'origin') {
354
+ if (!isGitInstalled()) return { ok: false, error: 'git not installed' };
355
+
356
+ // Current upstream — empty string means none configured.
357
+ let current = '';
358
+ try {
359
+ current = execFileSync('git', ['rev-parse', '--abbrev-ref', '@{upstream}'], {
360
+ cwd,
361
+ encoding: 'utf8',
362
+ stdio: ['pipe', 'pipe', 'pipe'],
363
+ }).trim();
364
+ } catch {
365
+ // No upstream set — fall through.
366
+ }
367
+
368
+ if (current && current === `${remote}/${branch}`) {
369
+ return { ok: true, action: 'unchanged' };
370
+ }
371
+
372
+ // Verify the remote exists before trying to set upstream to it.
373
+ try {
374
+ execFileSync('git', ['rev-parse', '--verify', remote], {
375
+ cwd,
376
+ stdio: ['pipe', 'pipe', 'pipe'],
377
+ });
378
+ } catch {
379
+ return { ok: false, error: `remote '${remote}' does not exist` };
380
+ }
381
+
382
+ try {
383
+ execFileSync('git', ['branch', '--set-upstream-to', `${remote}/${branch}`], {
384
+ cwd,
385
+ stdio: ['pipe', 'pipe', 'pipe'],
386
+ });
387
+ return { ok: true, action: 'set' };
388
+ } catch (err) {
389
+ return { ok: false, error: err.message };
390
+ }
391
+ }
@@ -11,7 +11,7 @@
11
11
  * initVault time (F7 invariant).
12
12
  */
13
13
 
14
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
14
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
15
15
  import { execFileSync } from 'node:child_process';
16
16
  import { join, dirname, relative, resolve as pathResolve, sep } from 'node:path';
17
17
  import { homedir } from 'node:os';
@@ -27,15 +27,51 @@ const BIZAR_MEMORY_ROOT = join(HOME, '.local', 'share', 'bizar', 'memory');
27
27
  /**
28
28
  * Path safety: reject any relPath that would escape the vault root.
29
29
  *
30
+ * Defenses (in order):
31
+ * 1. Empty / null-byte injection → reject.
32
+ * 2. Explicit segment check for `..` and absolute path prefixes → reject
33
+ * before path.resolve normalizes them away. The check is on SEGMENTS
34
+ * (`..` separated by `/` or `\`), so filenames like `my..note.md` are
35
+ * still allowed.
36
+ * 3. Post-resolve check: the resulting absolute path must be inside
37
+ * vaultRoot (relative path must not start with `..`).
38
+ * 4. Symlink guard: if the resolved path is a pre-existing symlink, refuse.
39
+ * This blocks an attacker who placed a symlink inside the vault pointing
40
+ * outside from writing/reading through it.
41
+ *
42
+ * Known limitation: the symlink guard is not TOCTOU-safe. A swap between
43
+ * lstatSync and the subsequent read/write/delete is still possible. For a
44
+ * full TOCTOU fix the read/write/delete would need to use O_NOFOLLOW and
45
+ * operate on an open fd, not a path. This is acceptable for the current
46
+ * threat model (the vault is a per-user directory, not a multi-tenant FS).
47
+ *
30
48
  * @param {string} vaultRoot
31
49
  * @param {string} relPath
32
50
  * @returns {string | null} — resolved absolute path, or null if unsafe
33
51
  */
34
52
  function resolveSafe(vaultRoot, relPath) {
35
53
  if (!relPath || relPath.includes('\0')) return null;
54
+ // Segment-level check: reject any `..` segment or absolute-path prefix
55
+ // BEFORE path.resolve normalizes them away. This explicitly rejects
56
+ // paths like `notes/../escape.md`, while still allowing filenames like
57
+ // `my..note.md` (the substring `..` inside a single segment is fine).
58
+ const segments = relPath.split(/[\\/]+/);
59
+ if (segments.includes('..') || segments[0] === '') return null;
36
60
  const abs = pathResolve(vaultRoot, relPath);
37
61
  const rel = relative(vaultRoot, abs);
38
62
  if (rel.startsWith('..') || abs !== pathResolve(abs)) return null;
63
+ // Symlink guard (see note above re: TOCTOU). Note: existsSync follows
64
+ // symlinks, so we MUST use lstatSync directly — existsSync + isSymbolicLink
65
+ // misses dangling symlinks (target doesn't exist). lstatSync returns a
66
+ // Stats object even for dangling symlinks; catch only unexpected errors
67
+ // (e.g. EACCES on the parent directory).
68
+ try {
69
+ if (lstatSync(abs).isSymbolicLink()) return null;
70
+ } catch {
71
+ // Path doesn't exist or parent dir inaccessible — not a symlink,
72
+ // proceed. (A fresh write won't traverse a symlink because the
73
+ // symlink itself doesn't exist yet at write time.)
74
+ }
39
75
  return abs;
40
76
  }
41
77
 
@@ -120,6 +156,31 @@ export function resolveVault(projectRoot) {
120
156
  return { mode, projectId, vaultRoot, repoPath, configDir, gitRemote, branch, lightragDir, namespaces };
121
157
  }
122
158
 
159
+ /**
160
+ * Resolve the absolute path of a namespace root.
161
+ *
162
+ * For `project`, returns vaultRoot (the existing default). For `global` and
163
+ * `user`, returns `<vaultRoot>/<namespaces.global|user>` in both modes —
164
+ * `writeNote('global/bizar/foo.md', …)` already places files there in both
165
+ * local-only and managed mode, so this matches the on-disk layout that
166
+ * existing callers rely on.
167
+ *
168
+ * (Note: an earlier draft distinguished local-only vs managed, putting
169
+ * global under `<repoPath>/global/bizar` in managed mode. That diverged from
170
+ * where writeNote actually creates the file, which is always under
171
+ * vaultRoot. This implementation matches the on-disk reality.)
172
+ *
173
+ * @param {{ mode: string, vaultRoot: string, namespaces: object }} vaultInfo
174
+ * @param {'project'|'global'|'user'} namespace
175
+ * @returns {string|null} absolute path, or null if the namespace is unknown
176
+ */
177
+ export function resolveNamespaceRoot(vaultInfo, namespace) {
178
+ if (namespace === 'project') return vaultInfo.vaultRoot;
179
+ const ns = vaultInfo.namespaces?.[namespace];
180
+ if (!ns) return null;
181
+ return join(vaultInfo.vaultRoot, ns);
182
+ }
183
+
123
184
  /**
124
185
  * Initialize the vault for a project. In local-only mode, creates .obsidian/
125
186
  * and standard subdirectories. In managed mode, the shared repo is assumed to
@@ -226,15 +287,20 @@ export function initVault(projectRoot) {
226
287
  * List all notes in the vault. Returns enriched note metadata.
227
288
  *
228
289
  * @param {string} projectRoot
229
- * @param {{ namespace?: string }} [opts]
290
+ * @param {{ namespace?: string, root?: string }} [opts]
291
+ * `namespace`: subdirectory under `root` to walk (e.g. `projects/<id>`).
292
+ * `root`: override the root used as the walking base. Defaults to
293
+ * `vaultRoot` from resolveVault. Used by namespace helpers
294
+ * in cli/memory.mjs to list global / user notes.
230
295
  * @returns {Array<{ relPath: string, mtime: number, size: number, frontmatter: Record<string, unknown>, body: string, schemaValid: boolean }>}
231
296
  */
232
297
  export function listNotes(projectRoot, opts = {}) {
233
298
  const { vaultRoot } = resolveVault(projectRoot);
234
- if (!existsSync(vaultRoot)) return [];
299
+ const root = opts.root || vaultRoot;
300
+ if (!existsSync(root)) return [];
235
301
 
236
302
  const namespace = opts.namespace || '';
237
- const searchRoot = namespace ? join(vaultRoot, namespace) : vaultRoot;
303
+ const searchRoot = namespace ? join(root, namespace) : root;
238
304
  if (!existsSync(searchRoot)) return [];
239
305
 
240
306
  const out = [];
@@ -268,11 +334,14 @@ export function listNotes(projectRoot, opts = {}) {
268
334
  *
269
335
  * @param {string} projectRoot
270
336
  * @param {string} relPath
337
+ * @param {{ root?: string }} [opts] optional override for the root used by
338
+ * resolveSafe; defaults to vaultRoot
271
339
  * @returns {{ relPath: string, frontmatter: Record<string, unknown>, body: string, raw: string, mtime: number, size: number, schemaValid: boolean } | null}
272
340
  */
273
- export function readNote(projectRoot, relPath) {
274
- const { vaultRoot } = resolveVault(projectRoot);
275
- const filePath = resolveSafe(vaultRoot, relPath);
341
+ export function readNote(projectRoot, relPath, opts = {}) {
342
+ const vaultInfo = resolveVault(projectRoot);
343
+ const root = opts.root || vaultInfo.vaultRoot;
344
+ const filePath = resolveSafe(root, relPath);
276
345
  if (!filePath || !existsSync(filePath)) return null;
277
346
  try {
278
347
  const raw = readFileSync(filePath, 'utf8');
@@ -378,11 +447,14 @@ export function writeNote(projectRoot, relPath, { frontmatter, body }) {
378
447
  *
379
448
  * @param {string} projectRoot
380
449
  * @param {string} relPath
450
+ * @param {{ root?: string }} [opts] optional override for the root used by
451
+ * resolveSafe; defaults to vaultRoot
381
452
  * @returns {boolean}
382
453
  */
383
- export function deleteNote(projectRoot, relPath) {
384
- const { vaultRoot } = resolveVault(projectRoot);
385
- const filePath = resolveSafe(vaultRoot, relPath);
454
+ export function deleteNote(projectRoot, relPath, opts = {}) {
455
+ const vaultInfo = resolveVault(projectRoot);
456
+ const root = opts.root || vaultInfo.vaultRoot;
457
+ const filePath = resolveSafe(root, relPath);
386
458
  if (!filePath || !existsSync(filePath)) return false;
387
459
  unlinkSync(filePath);
388
460
  return true;
@@ -0,0 +1,405 @@
1
+ /**
2
+ * tests/memory-cli-readlistdelete.test.mjs
3
+ *
4
+ * Integration tests for the `bizar memory read`, `bizar memory list`, and
5
+ * `bizar memory delete` CLI subcommands.
6
+ *
7
+ * The CLI calls `process.exit(1)` on errors, so we spawn the `bizar` binary
8
+ * as a subprocess in a temp dir, assert on exit code + stdout/stderr, and
9
+ * clean up after.
10
+ *
11
+ * These tests are designed to pass once Tyr lands the cmdRead / cmdList /
12
+ * cmdDelete implementations. They validate the expected CLI interface
13
+ * (exit codes, output shapes, and file-system side-effects) rather than
14
+ * the internal store API directly.
15
+ */
16
+
17
+ import { test, describe, beforeEach, afterEach } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { tmpdir } from 'node:os';
20
+ import { join, dirname } from 'node:path';
21
+ import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
22
+ import { execFileSync } from 'node:child_process';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = dirname(__filename);
27
+
28
+ // Resolve path to the `bizar` CLI bin — invoke via `node <absolute path>`
29
+ // so the test does not depend on PATH.
30
+ const CLI_BIN = join(__dirname, '..', '..', 'cli', 'bin.mjs');
31
+
32
+ /**
33
+ * Spawn `bizar memory <subcommand> …` in the given cwd and capture exit
34
+ * code, stdout, and stderr. Returns `{ code, stdout, stderr }`.
35
+ */
36
+ function runBizarMemory(subcmd, args, cwd) {
37
+ try {
38
+ const stdout = execFileSync('node', [CLI_BIN, 'memory', subcmd, ...args], {
39
+ cwd,
40
+ encoding: 'utf8',
41
+ stdio: 'pipe',
42
+ timeout: 15_000,
43
+ });
44
+ return { code: 0, stdout, stderr: '' };
45
+ } catch (err) {
46
+ return {
47
+ code: err.status ?? 1,
48
+ stdout: err.stdout ? err.stdout.toString() : '',
49
+ stderr: err.stderr ? err.stderr.toString() : (err.message ?? ''),
50
+ };
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Write a minimal `.bizar/memory.json` for local-only operation.
56
+ */
57
+ function writeMemoryConfig(cwd, overrides = {}) {
58
+ const projectId = overrides.projectId ?? `rlt-${Date.now()}`;
59
+ const config = {
60
+ version: 1,
61
+ backend: 'bizar-local',
62
+ projectId,
63
+ memoryRepo: {
64
+ mode: 'local-only',
65
+ path: join(cwd, '.obsidian'),
66
+ remote: null,
67
+ branch: 'main',
68
+ namespace: null,
69
+ },
70
+ namespaces: {
71
+ project: `projects/${projectId}`,
72
+ global: 'global/bizar',
73
+ user: 'users/tester',
74
+ },
75
+ lightrag: {
76
+ enabled: false,
77
+ host: '127.0.0.1',
78
+ port: 9621,
79
+ workingDir: join(cwd, '.bizar', 'lightrag'),
80
+ },
81
+ git: {
82
+ autoPullOnSessionStart: false,
83
+ autoCommitOnMemoryWrite: false,
84
+ autoPushOnSessionEnd: false,
85
+ commitAuthor: 'Bizar Memory <bizar-memory@local>',
86
+ commitMessageTemplate: `memory(${projectId}): {summary}`,
87
+ },
88
+ };
89
+ mkdirSync(join(cwd, '.bizar'), { recursive: true });
90
+ writeFileSync(join(cwd, '.bizar', 'memory.json'), JSON.stringify(config, null, 2));
91
+ }
92
+
93
+ // ─── bizar memory read ────────────────────────────────────────────────────────
94
+
95
+ describe('bizar memory read', () => {
96
+ let projectRoot;
97
+
98
+ beforeEach(() => {
99
+ projectRoot = join(
100
+ tmpdir(),
101
+ `bizar-memread-${Date.now()}-${Math.random().toString(36).slice(2)}`,
102
+ );
103
+ mkdirSync(projectRoot, { recursive: true });
104
+ mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
105
+ mkdirSync(join(projectRoot, '.obsidian'), { recursive: true });
106
+ writeMemoryConfig(projectRoot);
107
+ });
108
+
109
+ afterEach(() => {
110
+ try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
111
+ });
112
+
113
+ test('happy path — prints frontmatter + body to stdout and exits 0', () => {
114
+ // First write a note via the CLI (validates the full chain)
115
+ const relPath = 'decisions/0001-read-test.md';
116
+ const writeRes = runBizarMemory(
117
+ 'write',
118
+ [
119
+ relPath,
120
+ '--type', 'architecture_decision',
121
+ '--status', 'active',
122
+ '--confidence', 'verified',
123
+ '--title', 'Read test note',
124
+ '--body', '# Read test note\n\nBody content here.',
125
+ ],
126
+ projectRoot,
127
+ );
128
+ assert.strictEqual(
129
+ writeRes.code,
130
+ 0,
131
+ `write failed: ${writeRes.stderr}`,
132
+ );
133
+
134
+ // Now read it back
135
+ const r = runBizarMemory('read', [relPath], projectRoot);
136
+ assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}; stderr=${r.stderr}`);
137
+ assert.ok(r.stdout.includes('Body content here.'), 'body should appear in stdout');
138
+ assert.ok(
139
+ r.stdout.includes('Read test note') || r.stdout.includes('architecture_decision'),
140
+ 'stdout should include title or type from frontmatter',
141
+ );
142
+ });
143
+
144
+ test('not found — exits 1 and reports the missing path', () => {
145
+ const r = runBizarMemory('read', ['does-not-exist.md'], projectRoot);
146
+ assert.strictEqual(r.code, 1, `expected exit 1, got ${r.code}`);
147
+ const out = `${r.stdout}\n${r.stderr}`;
148
+ assert.ok(
149
+ /not found|does.not.exist|no such file/i.test(out),
150
+ `expected a "not found" message, got: ${out}`,
151
+ );
152
+ });
153
+ });
154
+
155
+ // ─── bizar memory list ─────────────────────────────────────────────────────────
156
+
157
+ describe('bizar memory list', () => {
158
+ let projectRoot;
159
+
160
+ beforeEach(() => {
161
+ projectRoot = join(
162
+ tmpdir(),
163
+ `bizar-memlist-${Date.now()}-${Math.random().toString(36).slice(2)}`,
164
+ );
165
+ mkdirSync(projectRoot, { recursive: true });
166
+ mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
167
+ mkdirSync(join(projectRoot, '.obsidian'), { recursive: true });
168
+ writeMemoryConfig(projectRoot);
169
+ });
170
+
171
+ afterEach(() => {
172
+ try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
173
+ });
174
+
175
+ test('empty vault — exits 0 and prints nothing (or a zero count)', () => {
176
+ const r = runBizarMemory('list', [], projectRoot);
177
+ assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}; stderr=${r.stderr}`);
178
+ // stdout may be empty or show "0 notes" — either is acceptable
179
+ const out = r.stdout.trim();
180
+ // If non-empty it should not contain any .md paths
181
+ if (out.length > 0) {
182
+ assert.ok(
183
+ !/\.md/.test(out),
184
+ `empty vault should not list .md files; got: ${out}`,
185
+ );
186
+ }
187
+ });
188
+
189
+ test('with notes — lists all top-level notes', () => {
190
+ const notes = [
191
+ 'decisions/0001-list-test-a.md',
192
+ 'decisions/0001-list-test-b.md',
193
+ 'decisions/0001-list-test-c.md',
194
+ ];
195
+ for (const relPath of notes) {
196
+ const r = runBizarMemory(
197
+ 'write',
198
+ [relPath, '--type', 'architecture_decision', '--body', `# ${relPath}`],
199
+ projectRoot,
200
+ );
201
+ assert.strictEqual(r.code, 0, `write failed for ${relPath}: ${r.stderr}`);
202
+ }
203
+
204
+ const listRes = runBizarMemory('list', [], projectRoot);
205
+ assert.strictEqual(listRes.code, 0, `list failed: ${listRes.stderr}`);
206
+ for (const relPath of notes) {
207
+ assert.ok(
208
+ listRes.stdout.includes(relPath),
209
+ `list output should contain ${relPath}; got: ${listRes.stdout}`,
210
+ );
211
+ }
212
+ });
213
+
214
+ test('with subdir filter — only lists notes under the specified directory', () => {
215
+ // Write notes in two different directories
216
+ const decisionsNotes = [
217
+ 'decisions/0001-subdir-test.md',
218
+ 'decisions/0002-subdir-test.md',
219
+ ];
220
+ const tasksNotes = ['tasks/0001-subdir-test.md'];
221
+
222
+ for (const relPath of [...decisionsNotes, ...tasksNotes]) {
223
+ const r = runBizarMemory(
224
+ 'write',
225
+ [relPath, '--type', relPath.startsWith('decisions') ? 'architecture_decision' : 'session_summary', '--body', `# ${relPath}`],
226
+ projectRoot,
227
+ );
228
+ assert.strictEqual(r.code, 0, `write failed for ${relPath}: ${r.stderr}`);
229
+ }
230
+
231
+ // List only decisions/
232
+ const listRes = runBizarMemory('list', ['decisions/'], projectRoot);
233
+ assert.strictEqual(listRes.code, 0, `list failed: ${listRes.stderr}`);
234
+ for (const relPath of decisionsNotes) {
235
+ assert.ok(
236
+ listRes.stdout.includes(relPath),
237
+ `list decisions/ output should contain ${relPath}; got: ${listRes.stdout}`,
238
+ );
239
+ }
240
+ for (const relPath of tasksNotes) {
241
+ assert.ok(
242
+ !listRes.stdout.includes(relPath),
243
+ `list decisions/ output should NOT contain ${relPath}; got: ${listRes.stdout}`,
244
+ );
245
+ }
246
+ });
247
+ });
248
+
249
+ // ─── bizar memory delete ───────────────────────────────────────────────────────
250
+
251
+ describe('bizar memory delete', () => {
252
+ let projectRoot;
253
+
254
+ beforeEach(() => {
255
+ projectRoot = join(
256
+ tmpdir(),
257
+ `bizar-memdel-${Date.now()}-${Math.random().toString(36).slice(2)}`,
258
+ );
259
+ mkdirSync(projectRoot, { recursive: true });
260
+ mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
261
+ mkdirSync(join(projectRoot, '.obsidian'), { recursive: true });
262
+ writeMemoryConfig(projectRoot);
263
+ });
264
+
265
+ afterEach(() => {
266
+ try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
267
+ });
268
+
269
+ test('happy path — deletes the note and exits 0', () => {
270
+ const relPath = 'decisions/0001-delete-test.md';
271
+ // First write
272
+ const writeRes = runBizarMemory(
273
+ 'write',
274
+ [relPath, '--type', 'architecture_decision', '--body', '# To be deleted'],
275
+ projectRoot,
276
+ );
277
+ assert.strictEqual(writeRes.code, 0, `write failed: ${writeRes.stderr}`);
278
+
279
+ const filePath = join(projectRoot, '.obsidian', relPath);
280
+ assert.ok(
281
+ existsSync(filePath),
282
+ `file should exist before delete: ${filePath}`,
283
+ );
284
+
285
+ // Delete
286
+ const r = runBizarMemory('delete', [relPath], projectRoot);
287
+ assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}; stderr=${r.stderr}`);
288
+
289
+ // File should be gone
290
+ assert.ok(
291
+ !existsSync(filePath),
292
+ `file should NOT exist after delete: ${filePath}`,
293
+ );
294
+ });
295
+
296
+ test('not found — exits 1 and reports the missing path', () => {
297
+ const r = runBizarMemory('delete', ['nonexistent-note.md'], projectRoot);
298
+ assert.strictEqual(r.code, 1, `expected exit 1, got ${r.code}`);
299
+ const out = `${r.stdout}\n${r.stderr}`;
300
+ assert.ok(
301
+ /not found|no such file|does.not.exist/i.test(out),
302
+ `expected a "not found" message, got: ${out}`,
303
+ );
304
+ });
305
+
306
+ test('nested path — deletes a note in a subdirectory', () => {
307
+ const relPath = 'decisions/nested/deeply/note.md';
308
+ // Write
309
+ const writeRes = runBizarMemory(
310
+ 'write',
311
+ [relPath, '--type', 'architecture_decision', '--body', '# Nested note'],
312
+ projectRoot,
313
+ );
314
+ assert.strictEqual(writeRes.code, 0, `write failed: ${writeRes.stderr}`);
315
+
316
+ const filePath = join(projectRoot, '.obsidian', relPath);
317
+ assert.ok(existsSync(filePath), `file should exist before delete: ${filePath}`);
318
+
319
+ // Delete
320
+ const r = runBizarMemory('delete', [relPath], projectRoot);
321
+ assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}; stderr=${r.stderr}`);
322
+
323
+ // File should be gone
324
+ assert.ok(
325
+ !existsSync(filePath),
326
+ `file should NOT exist after delete: ${filePath}`,
327
+ );
328
+ });
329
+ });
330
+
331
+ // ─── end-to-end: write → read → list → delete → list → read ──────────────────
332
+
333
+ describe('bizar memory E2E — write → read → list → delete → list → read', () => {
334
+ let projectRoot;
335
+
336
+ beforeEach(() => {
337
+ projectRoot = join(
338
+ tmpdir(),
339
+ `bizar-meme2e-${Date.now()}-${Math.random().toString(36).slice(2)}`,
340
+ );
341
+ mkdirSync(projectRoot, { recursive: true });
342
+ mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
343
+ mkdirSync(join(projectRoot, '.obsidian'), { recursive: true });
344
+ writeMemoryConfig(projectRoot);
345
+ });
346
+
347
+ afterEach(() => {
348
+ try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
349
+ });
350
+
351
+ test('full round-trip through the CLI', () => {
352
+ const relPath = 'decisions/0001-e2e.md';
353
+ const bodyText = `# E2E test note
354
+
355
+ This body should survive the full round-trip through the CLI.`;
356
+
357
+ // Step 1: write
358
+ const writeRes = runBizarMemory('write', [
359
+ relPath,
360
+ '--type', 'architecture_decision',
361
+ '--status', 'active',
362
+ '--confidence', 'verified',
363
+ '--title', 'E2E test note',
364
+ '--body', bodyText,
365
+ ], projectRoot);
366
+ assert.strictEqual(writeRes.code, 0, `write failed: ${writeRes.stderr}`);
367
+
368
+ // Step 2: read — should succeed and contain the body
369
+ const readRes = runBizarMemory('read', [relPath], projectRoot);
370
+ assert.strictEqual(readRes.code, 0, `read failed: ${readRes.stderr}`);
371
+ assert.ok(
372
+ readRes.stdout.includes('round-trip'),
373
+ `read output should contain body text; got: ${readRes.stdout}`,
374
+ );
375
+
376
+ // Step 3: list — should show the note
377
+ const listRes = runBizarMemory('list', [], projectRoot);
378
+ assert.strictEqual(listRes.code, 0, `list failed: ${listRes.stderr}`);
379
+ assert.ok(
380
+ listRes.stdout.includes(relPath),
381
+ `list should contain ${relPath}; got: ${listRes.stdout}`,
382
+ );
383
+
384
+ // Step 4: delete
385
+ const deleteRes = runBizarMemory('delete', [relPath], projectRoot);
386
+ assert.strictEqual(deleteRes.code, 0, `delete failed: ${deleteRes.stderr}`);
387
+
388
+ // Step 5: list again — should NOT show the note
389
+ const listAfterRes = runBizarMemory('list', [], projectRoot);
390
+ assert.strictEqual(listAfterRes.code, 0, `list after delete failed: ${listAfterRes.stderr}`);
391
+ assert.ok(
392
+ !listAfterRes.stdout.includes(relPath),
393
+ `list after delete should NOT contain ${relPath}; got: ${listAfterRes.stdout}`,
394
+ );
395
+
396
+ // Step 6: read — should fail with not-found
397
+ const readAfterRes = runBizarMemory('read', [relPath], projectRoot);
398
+ assert.strictEqual(readAfterRes.code, 1, `read after delete should exit 1, got ${readAfterRes.code}`);
399
+ const out = `${readAfterRes.stdout}\n${readAfterRes.stderr}`;
400
+ assert.ok(
401
+ /not found|no such file|does.not.exist/i.test(out),
402
+ `read after delete should report not found; got: ${out}`,
403
+ );
404
+ });
405
+ });