@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.
- package/bizar-dash/src/server/memory-git.mjs +62 -0
- package/bizar-dash/src/server/memory-store.mjs +82 -10
- package/bizar-dash/tests/memory-cli-readlistdelete.test.mjs +405 -0
- package/bizar-dash/tests/memory-cli-setup.test.mjs +382 -0
- package/bizar-dash/tests/memory-cli.test.mjs +54 -0
- package/bizar-dash/tests/memory-conflicts.test.mjs +229 -0
- package/bizar-dash/tests/memory-namespace.test.mjs +404 -0
- package/bizar-dash/tests/memory-path-safety.test.mjs +427 -0
- package/bizar-dash/tests/memory-roundtrip.test.mjs +219 -0
- package/cli/memory.mjs +273 -6
- package/package.json +2 -2
|
@@ -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
|
-
|
|
299
|
+
const root = opts.root || vaultRoot;
|
|
300
|
+
if (!existsSync(root)) return [];
|
|
235
301
|
|
|
236
302
|
const namespace = opts.namespace || '';
|
|
237
|
-
const searchRoot = namespace ? join(
|
|
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
|
|
275
|
-
const
|
|
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
|
|
385
|
-
const
|
|
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
|
+
});
|