@polderlabs/bizar 4.0.0 → 4.2.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 +11 -14
- package/bizar-dash/CHANGELOG.md +1 -1
- package/bizar-dash/src/server/api.mjs +2 -2
- package/bizar-dash/src/server/artifacts-store.mjs +4 -4
- package/bizar-dash/src/server/memory-git.mjs +142 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +767 -0
- package/bizar-dash/src/server/memory-store.mjs +129 -10
- package/bizar-dash/src/server/mod-security.mjs +2 -2
- package/bizar-dash/src/server/routes/memory.mjs +174 -15
- package/bizar-dash/src/server/server.mjs +1 -1
- package/bizar-dash/src/server/state.mjs +2 -2
- package/bizar-dash/src/web/views/Config.tsx +461 -1
- 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 +542 -0
- package/bizar-dash/tests/memory-config.test.mjs +422 -0
- package/bizar-dash/tests/memory-conflicts.test.mjs +229 -0
- package/bizar-dash/tests/memory-git.test.mjs +109 -1
- package/bizar-dash/tests/memory-lightrag.test.mjs +153 -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-protocol-drift.test.mjs +45 -0
- package/bizar-dash/tests/memory-roundtrip.test.mjs +219 -0
- package/cli/banner.mjs +1 -1
- package/cli/bin.mjs +4 -4
- package/cli/bootstrap.mjs +1 -1
- package/cli/copy.mjs +22 -16
- package/cli/doctor.mjs +4 -4
- package/cli/doctor.test.mjs +2 -2
- package/cli/init.mjs +2 -2
- package/cli/install.mjs +21 -16
- package/cli/memory.mjs +952 -37
- package/cli/utils.mjs +6 -3
- package/config/AGENTS.md +7 -7
- package/config/agents/_shared/AGENT_BASELINE.md +59 -61
- package/config/opencode.json +13 -38
- package/config/skills/memory-protocol/SKILL.md +105 -0
- package/config/skills/obsidian/SKILL.md +58 -1
- package/install.sh +11 -1
- package/package.json +2 -2
- package/plugins/bizar/index.ts +7 -0
- package/plugins/bizar/src/commands.ts +42 -1
- package/plugins/bizar/src/tools/open-kb.ts +191 -0
- package/plugins/bizar/tests/commands.test.ts +36 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/memory-cli.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Integration tests for the `bizar memory write` CLI subcommand.
|
|
5
|
+
*
|
|
6
|
+
* The CLI calls `process.exit(1)` on errors, so we cannot import runMemory
|
|
7
|
+
* directly — we spawn the `bizar` binary as a subprocess in a temp dir,
|
|
8
|
+
* assert on its exit code + stdout/stderr, and clean up after.
|
|
9
|
+
*
|
|
10
|
+
* v4.1.0 — added to close the gap that agents (which have bash but no
|
|
11
|
+
* Memory MCP) have no programmatic way to write memory notes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join, dirname } from 'node:path';
|
|
18
|
+
import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import { execFileSync } from 'node:child_process';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
23
|
+
const __dirname = dirname(__filename);
|
|
24
|
+
|
|
25
|
+
// Resolve path to the `bizar` CLI bin (this package's bin). We invoke it
|
|
26
|
+
// via `node <absolute path>` so the test does not depend on PATH.
|
|
27
|
+
const CLI_BIN = join(__dirname, '..', '..', 'cli', 'bin.mjs');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Spawn `bizar memory write …` in the given cwd and capture exit code,
|
|
31
|
+
* stdout, and stderr. Returns `{ code, stdout, stderr }`.
|
|
32
|
+
*/
|
|
33
|
+
function runBizarMemoryWrite(cwd, args) {
|
|
34
|
+
try {
|
|
35
|
+
const stdout = execFileSync('node', [CLI_BIN, 'memory', 'write', ...args], {
|
|
36
|
+
cwd,
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
stdio: 'pipe',
|
|
39
|
+
timeout: 15_000,
|
|
40
|
+
});
|
|
41
|
+
return { code: 0, stdout, stderr: '' };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return {
|
|
44
|
+
code: err.status ?? 1,
|
|
45
|
+
stdout: err.stdout ? err.stdout.toString() : '',
|
|
46
|
+
stderr: err.stderr ? err.stderr.toString() : (err.message ?? ''),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function runBizarMemory(cwd, subcommand, args) {
|
|
52
|
+
try {
|
|
53
|
+
const stdout = execFileSync('node', [CLI_BIN, 'memory', subcommand, ...args], {
|
|
54
|
+
cwd,
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
stdio: 'pipe',
|
|
57
|
+
timeout: 15_000,
|
|
58
|
+
});
|
|
59
|
+
return { code: 0, stdout, stderr: '' };
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return {
|
|
62
|
+
code: err.status ?? 1,
|
|
63
|
+
stdout: err.stdout ? err.stdout.toString() : '',
|
|
64
|
+
stderr: err.stderr ? err.stderr.toString() : (err.message ?? ''),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeMemoryConfig(cwd, overrides = {}) {
|
|
70
|
+
const projectId = overrides.projectId ?? 'test-proj';
|
|
71
|
+
const config = {
|
|
72
|
+
version: 1,
|
|
73
|
+
backend: 'bizar-local',
|
|
74
|
+
projectId,
|
|
75
|
+
memoryRepo: {
|
|
76
|
+
mode: 'local-only',
|
|
77
|
+
path: join(cwd, '.obsidian'),
|
|
78
|
+
remote: null,
|
|
79
|
+
branch: 'main',
|
|
80
|
+
namespace: null,
|
|
81
|
+
},
|
|
82
|
+
namespaces: {
|
|
83
|
+
project: `projects/${projectId}`,
|
|
84
|
+
global: 'global/bizar',
|
|
85
|
+
user: 'users/tester',
|
|
86
|
+
},
|
|
87
|
+
lightrag: {
|
|
88
|
+
enabled: false,
|
|
89
|
+
host: '127.0.0.1',
|
|
90
|
+
port: 9621,
|
|
91
|
+
workingDir: join(cwd, '.bizar', 'lightrag'),
|
|
92
|
+
},
|
|
93
|
+
git: {
|
|
94
|
+
autoPullOnSessionStart: false,
|
|
95
|
+
autoCommitOnMemoryWrite: false,
|
|
96
|
+
autoPushOnSessionEnd: false,
|
|
97
|
+
commitAuthor: 'Bizar Memory <bizar-memory@local>',
|
|
98
|
+
commitMessageTemplate: `memory(${projectId}): {summary}`,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
writeFileSync(join(cwd, '.bizar', 'memory.json'), JSON.stringify(config, null, 2));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('bizar memory write CLI', () => {
|
|
105
|
+
let projectRoot;
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
projectRoot = join(tmpdir(), `bizar-memcli-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
109
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
110
|
+
mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
|
|
111
|
+
writeMemoryConfig(projectRoot);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('prints help with --help flag', () => {
|
|
119
|
+
const r = runBizarMemory(projectRoot, 'write', ['--help']);
|
|
120
|
+
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}; stderr=${r.stderr}`);
|
|
121
|
+
assert.ok(r.stdout.includes('Usage:'), 'help text should include Usage:');
|
|
122
|
+
assert.ok(r.stdout.includes('--type'), 'help text should document --type');
|
|
123
|
+
assert.ok(r.stdout.includes('--body'), 'help text should document --body');
|
|
124
|
+
assert.ok(r.stdout.includes('--body-file'), 'help text should document --body-file');
|
|
125
|
+
assert.ok(r.stdout.includes('--json'), 'help text should document --json');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('rejects call with no relpath (no positional, no body)', () => {
|
|
129
|
+
const r = runBizarMemory(projectRoot, 'write', []);
|
|
130
|
+
assert.strictEqual(r.code, 1, `expected exit 1, got ${r.code}; stderr=${r.stderr}`);
|
|
131
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
132
|
+
assert.ok(/relpath is required/i.test(out), `expected relpath error, got: ${out}`);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('writes a note successfully with --body and verifies the file', () => {
|
|
136
|
+
const relPath = 'decisions/0001-cli-test.md';
|
|
137
|
+
const r = runBizarMemoryWrite(projectRoot, [
|
|
138
|
+
relPath,
|
|
139
|
+
'--type', 'architecture_decision',
|
|
140
|
+
'--status', 'active',
|
|
141
|
+
'--confidence', 'verified',
|
|
142
|
+
'--tag', 'cli-test',
|
|
143
|
+
'--tag', 'integration',
|
|
144
|
+
'--title', 'CLI test note',
|
|
145
|
+
'--body', '# CLI Test\n\nHello from the CLI integration test.',
|
|
146
|
+
]);
|
|
147
|
+
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}; stderr=${r.stderr}; stdout=${r.stdout}`);
|
|
148
|
+
assert.ok(r.stdout.includes('wrote'), 'should print success line');
|
|
149
|
+
assert.ok(r.stdout.includes(relPath), 'should mention the relpath in output');
|
|
150
|
+
assert.ok(r.stdout.includes('architecture_decision'), 'output should include the type');
|
|
151
|
+
|
|
152
|
+
// Verify the note file actually exists in the vault
|
|
153
|
+
const filePath = join(projectRoot, '.obsidian', relPath);
|
|
154
|
+
assert.ok(existsSync(filePath), `note file should exist at ${filePath}`);
|
|
155
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
156
|
+
assert.ok(raw.startsWith('---\n'), 'note should start with frontmatter');
|
|
157
|
+
assert.ok(raw.includes('type: architecture_decision'), 'frontmatter should contain type');
|
|
158
|
+
assert.ok(raw.includes('cli-test'), 'frontmatter should contain tag');
|
|
159
|
+
assert.ok(raw.includes('# CLI Test'), 'body should be present');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('emits valid JSON when --json flag is passed', () => {
|
|
163
|
+
const r = runBizarMemoryWrite(projectRoot, [
|
|
164
|
+
'json-test.md',
|
|
165
|
+
'--type', 'command',
|
|
166
|
+
'--body', 'echo json test',
|
|
167
|
+
'--json',
|
|
168
|
+
]);
|
|
169
|
+
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}; stderr=${r.stderr}`);
|
|
170
|
+
const parsed = JSON.parse(r.stdout);
|
|
171
|
+
assert.strictEqual(parsed.relPath, 'json-test.md');
|
|
172
|
+
assert.strictEqual(parsed.frontmatter.type, 'command');
|
|
173
|
+
assert.ok(parsed.raw.includes('echo json test'), 'raw should contain body');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('rejects invalid --type with informative error', () => {
|
|
177
|
+
const r = runBizarMemoryWrite(projectRoot, [
|
|
178
|
+
'bad-type.md',
|
|
179
|
+
'--type', 'bogus_type',
|
|
180
|
+
'--body', 'should fail',
|
|
181
|
+
]);
|
|
182
|
+
assert.strictEqual(r.code, 1, `expected exit 1, got ${r.code}`);
|
|
183
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
184
|
+
assert.ok(/invalid --type/i.test(out), `expected type error, got: ${out}`);
|
|
185
|
+
assert.ok(out.includes('bogus_type'), 'error should mention the bad value');
|
|
186
|
+
// And the file should NOT exist
|
|
187
|
+
assert.ok(!existsSync(join(projectRoot, '.obsidian', 'bad-type.md')));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('rejects relpath that does not end in .md', () => {
|
|
191
|
+
const r = runBizarMemoryWrite(projectRoot, [
|
|
192
|
+
'decisions/notes.txt',
|
|
193
|
+
'--body', 'should fail',
|
|
194
|
+
]);
|
|
195
|
+
assert.strictEqual(r.code, 1, `expected exit 1, got ${r.code}`);
|
|
196
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
197
|
+
assert.ok(/must end in \.md/i.test(out), `expected .md error, got: ${out}`);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('rejects when --body and --body-file are both passed', () => {
|
|
201
|
+
const bodyFile = join(projectRoot, 'temp-body.md');
|
|
202
|
+
writeFileSync(bodyFile, 'body from file');
|
|
203
|
+
const r = runBizarMemoryWrite(projectRoot, [
|
|
204
|
+
'both.md',
|
|
205
|
+
'--body', 'inline body',
|
|
206
|
+
'--body-file', bodyFile,
|
|
207
|
+
]);
|
|
208
|
+
assert.strictEqual(r.code, 1, `expected exit 1, got ${r.code}`);
|
|
209
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
210
|
+
assert.ok(/--body or --body-file, not both/i.test(out), `expected mutual-exclusion error, got: ${out}`);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('reads body from --body-file when --body omitted', () => {
|
|
214
|
+
const bodyFile = join(projectRoot, 'body-content.md');
|
|
215
|
+
writeFileSync(bodyFile, '# From file\n\nThis came from a file.');
|
|
216
|
+
const r = runBizarMemoryWrite(projectRoot, [
|
|
217
|
+
'from-file.md',
|
|
218
|
+
'--type', 'session_summary',
|
|
219
|
+
'--body-file', bodyFile,
|
|
220
|
+
]);
|
|
221
|
+
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}; stderr=${r.stderr}`);
|
|
222
|
+
const filePath = join(projectRoot, '.obsidian', 'from-file.md');
|
|
223
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
224
|
+
assert.ok(raw.includes('From file'), 'body from file should be present');
|
|
225
|
+
assert.ok(raw.includes('session_summary'), 'frontmatter type should be set');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('rejects HIGH-severity secret in body', () => {
|
|
229
|
+
const r = runBizarMemoryWrite(projectRoot, [
|
|
230
|
+
'leak.md',
|
|
231
|
+
'--body', 'AKIAIOSFODNN7EXAMPLE is an AWS key',
|
|
232
|
+
]);
|
|
233
|
+
assert.strictEqual(r.code, 1, `expected exit 1, got ${r.code}`);
|
|
234
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
235
|
+
assert.ok(/secret/i.test(out), `expected secret-blocked message, got: ${out}`);
|
|
236
|
+
assert.ok(!existsSync(join(projectRoot, '.obsidian', 'leak.md')), 'leaked note must not be written');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('warns but succeeds on MEDIUM-severity finding', () => {
|
|
240
|
+
const r = runBizarMemoryWrite(projectRoot, [
|
|
241
|
+
'med.md',
|
|
242
|
+
'--body', 'api_key: abcdefghijklmnopqrstuvwxyz123456',
|
|
243
|
+
]);
|
|
244
|
+
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}; stderr=${r.stderr}`);
|
|
245
|
+
assert.ok(existsSync(join(projectRoot, '.obsidian', 'med.md')), 'medium-severity note should be written');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ─── bizar memory setup ──────────────────────────────────────────────────────
|
|
250
|
+
// v4.2.0 — the `setup` subcommand handles first-time bootstrap and remote
|
|
251
|
+
// reconfiguration. These tests spawn `bizar memory setup` in temp dirs and
|
|
252
|
+
// inspect the resulting `.bizar/memory.json`.
|
|
253
|
+
|
|
254
|
+
describe('bizar memory setup CLI', () => {
|
|
255
|
+
// Each test gets a fresh temp dir.
|
|
256
|
+
let setupRoot;
|
|
257
|
+
beforeEach(() => {
|
|
258
|
+
setupRoot = join(tmpdir(), `bizar-memsetup-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
259
|
+
mkdirSync(setupRoot, { recursive: true });
|
|
260
|
+
mkdirSync(join(setupRoot, '.bizar'), { recursive: true });
|
|
261
|
+
// NOTE: do NOT pre-create memory.json — these tests exercise the
|
|
262
|
+
// bootstrap path. The few "existing config" tests write their own.
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
afterEach(() => {
|
|
266
|
+
try { rmSync(setupRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Run `bizar memory setup` in `cwd` with the given args.
|
|
271
|
+
* @returns {{ code: number, stdout: string, stderr: string }}
|
|
272
|
+
*/
|
|
273
|
+
function runSetup(cwd, args) {
|
|
274
|
+
try {
|
|
275
|
+
const stdout = execFileSync('node', [CLI_BIN, 'memory', 'setup', ...args], {
|
|
276
|
+
cwd,
|
|
277
|
+
encoding: 'utf8',
|
|
278
|
+
stdio: 'pipe',
|
|
279
|
+
timeout: 30_000,
|
|
280
|
+
});
|
|
281
|
+
return { code: 0, stdout, stderr: '' };
|
|
282
|
+
} catch (err) {
|
|
283
|
+
return {
|
|
284
|
+
code: err.status ?? 1,
|
|
285
|
+
stdout: err.stdout ? err.stdout.toString() : '',
|
|
286
|
+
stderr: err.stderr ? err.stderr.toString() : (err.message ?? ''),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
test('prints usage with --help', () => {
|
|
292
|
+
const r = runSetup(setupRoot, ['--help']);
|
|
293
|
+
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}; stderr=${r.stderr}`);
|
|
294
|
+
assert.ok(r.stdout.includes('Usage:'), 'help should include Usage:');
|
|
295
|
+
assert.ok(r.stdout.includes('--remote'), 'help should document --remote');
|
|
296
|
+
assert.ok(r.stdout.includes('--mode'), 'help should document --mode');
|
|
297
|
+
assert.ok(r.stdout.includes('--non-interactive'), 'help should document --non-interactive');
|
|
298
|
+
assert.ok(r.stdout.includes('--local-only'), 'help should document --local-only');
|
|
299
|
+
assert.ok(/ssh:\/\/|https:\/\/|git@host:path/.test(r.stdout),
|
|
300
|
+
'help should mention the supported URL forms');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('bootstrap in local-only mode creates .bizar/memory.json', () => {
|
|
304
|
+
const repo = `test-local-${Date.now()}`;
|
|
305
|
+
const r = runSetup(setupRoot, [
|
|
306
|
+
'--non-interactive',
|
|
307
|
+
'--mode', 'local-only',
|
|
308
|
+
'--repo-name', repo,
|
|
309
|
+
]);
|
|
310
|
+
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}; stderr=${r.stderr}; stdout=${r.stdout}`);
|
|
311
|
+
const cfgPath = join(setupRoot, '.bizar', 'memory.json');
|
|
312
|
+
assert.ok(existsSync(cfgPath), `.bizar/memory.json should exist at ${cfgPath}`);
|
|
313
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
314
|
+
assert.strictEqual(cfg.memoryRepo.mode, 'local-only', 'mode should be local-only');
|
|
315
|
+
assert.strictEqual(cfg.memoryRepo.remote, null, 'local-only should have null remote');
|
|
316
|
+
// No top-level gitRemote in local-only mode
|
|
317
|
+
assert.ok(cfg.gitRemote === undefined || cfg.gitRemote === null || cfg.gitRemote === '',
|
|
318
|
+
'local-only mode should not set gitRemote');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('bootstrap with managed mode writes memoryRepo.remote AND gitRemote', () => {
|
|
322
|
+
const repo = `test-managed-${Date.now()}`;
|
|
323
|
+
const remote = `https://github.com/example-org/${repo}.git`;
|
|
324
|
+
const r = runSetup(setupRoot, [
|
|
325
|
+
'--non-interactive',
|
|
326
|
+
'--mode', 'managed',
|
|
327
|
+
'--repo-name', repo,
|
|
328
|
+
'--remote', remote,
|
|
329
|
+
]);
|
|
330
|
+
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}; stderr=${r.stderr}; stdout=${r.stdout}`);
|
|
331
|
+
const cfgPath = join(setupRoot, '.bizar', 'memory.json');
|
|
332
|
+
assert.ok(existsSync(cfgPath), '.bizar/memory.json must exist');
|
|
333
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
334
|
+
assert.strictEqual(cfg.memoryRepo.mode, 'managed', 'mode should be managed');
|
|
335
|
+
assert.strictEqual(cfg.memoryRepo.remote, remote,
|
|
336
|
+
`memoryRepo.remote should match the requested URL — got ${cfg.memoryRepo.remote}`);
|
|
337
|
+
assert.strictEqual(cfg.gitRemote, remote,
|
|
338
|
+
`top-level gitRemote should also be set so cmdPush works — got ${cfg.gitRemote}`);
|
|
339
|
+
// Vault path lives under ~/.local/share/bizar/memory/<repo-name>
|
|
340
|
+
assert.ok(cfg.memoryRepo.path.endsWith(repo), `vault path should end with ${repo}`);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('rejects an invalid remote URL with exit code 1', () => {
|
|
344
|
+
const r = runSetup(setupRoot, [
|
|
345
|
+
'--non-interactive',
|
|
346
|
+
'--mode', 'managed',
|
|
347
|
+
'--repo-name', `bad-${Date.now()}`,
|
|
348
|
+
'--remote', '/just/a/path/no/scheme',
|
|
349
|
+
]);
|
|
350
|
+
assert.strictEqual(r.code, 1, `expected exit 1, got ${r.code}; stderr=${r.stderr}`);
|
|
351
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
352
|
+
assert.ok(/invalid.*remote|invalid --remote URL/i.test(out),
|
|
353
|
+
`expected URL validation error, got: ${out}`);
|
|
354
|
+
// No config should be written
|
|
355
|
+
const cfgPath = join(setupRoot, '.bizar', 'memory.json');
|
|
356
|
+
assert.ok(!existsSync(cfgPath),
|
|
357
|
+
'config should not be written when the remote URL is invalid');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('accepts the three supported URL forms', () => {
|
|
361
|
+
const forms = [
|
|
362
|
+
'git@github.com:org/repo.git',
|
|
363
|
+
'ssh://git@github.com/org/repo.git',
|
|
364
|
+
'https://github.com/org/repo.git',
|
|
365
|
+
];
|
|
366
|
+
for (const u of forms) {
|
|
367
|
+
const r = runSetup(setupRoot, [
|
|
368
|
+
'--non-interactive',
|
|
369
|
+
'--mode', 'managed',
|
|
370
|
+
'--repo-name', `forms-${Date.now()}`,
|
|
371
|
+
'--remote', u,
|
|
372
|
+
]);
|
|
373
|
+
assert.strictEqual(r.code, 0, `URL form ${u} should succeed, got ${r.code}; stderr=${r.stderr}`);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('rejects file:// and plain paths as URLs', () => {
|
|
378
|
+
// Empty string is filtered out by the CLI's optVal helper (treated as
|
|
379
|
+
// "no --remote passed"), so cmdSetup exits with a different — but still
|
|
380
|
+
// fatal — message. The pure unit tests above cover the empty-string
|
|
381
|
+
// case against validateRemoteUrl directly.
|
|
382
|
+
const bad = [
|
|
383
|
+
'file:///tmp/repo',
|
|
384
|
+
'/home/user/repos/my-vault',
|
|
385
|
+
];
|
|
386
|
+
for (const u of bad) {
|
|
387
|
+
const r = runSetup(setupRoot, [
|
|
388
|
+
'--non-interactive',
|
|
389
|
+
'--mode', 'managed',
|
|
390
|
+
'--repo-name', `bad-${Date.now()}`,
|
|
391
|
+
'--remote', u,
|
|
392
|
+
]);
|
|
393
|
+
assert.strictEqual(r.code, 1,
|
|
394
|
+
`URL "${u}" should be rejected, got exit ${r.code}; stderr=${r.stderr}`);
|
|
395
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
396
|
+
assert.ok(/invalid.*remote|invalid --remote URL/i.test(out),
|
|
397
|
+
`expected URL validation error for "${u}", got: ${out}`);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('--remote with empty value exits non-zero in non-interactive mode', () => {
|
|
402
|
+
// optVal treats a bare `--remote ""` as missing (it skips values that
|
|
403
|
+
// start with `-` and tolerates the next-token being empty), but a
|
|
404
|
+
// shell can also pass `--remote ""` through argv. Either way, the CLI
|
|
405
|
+
// must exit non-zero when no usable remote is available.
|
|
406
|
+
const r = runSetup(setupRoot, [
|
|
407
|
+
'--non-interactive',
|
|
408
|
+
'--mode', 'managed',
|
|
409
|
+
'--repo-name', `bad-${Date.now()}`,
|
|
410
|
+
'--remote', '',
|
|
411
|
+
]);
|
|
412
|
+
assert.strictEqual(r.code, 1,
|
|
413
|
+
`empty --remote should exit 1, got ${r.code}; stderr=${r.stderr}`);
|
|
414
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
415
|
+
// Could be either the URL-validation message OR the "required" message.
|
|
416
|
+
assert.ok(/--remote|remote URL/i.test(out),
|
|
417
|
+
`expected a remote-related error, got: ${out}`);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test('running setup twice with the same --remote is idempotent', () => {
|
|
421
|
+
const repo = `idem-${Date.now()}`;
|
|
422
|
+
const remote = `https://github.com/example-org/${repo}.git`;
|
|
423
|
+
const args = [
|
|
424
|
+
'--non-interactive',
|
|
425
|
+
'--mode', 'managed',
|
|
426
|
+
'--repo-name', repo,
|
|
427
|
+
'--remote', remote,
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
// First run: bootstrap
|
|
431
|
+
const r1 = runSetup(setupRoot, args);
|
|
432
|
+
assert.strictEqual(r1.code, 0, `first run should succeed, got ${r1.code}; stderr=${r1.stderr}`);
|
|
433
|
+
const cfgPath = join(setupRoot, '.bizar', 'memory.json');
|
|
434
|
+
const cfg1 = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
435
|
+
assert.strictEqual(cfg1.memoryRepo.remote, remote, 'first run should set the remote');
|
|
436
|
+
|
|
437
|
+
// Second run: should print "no changes" and exit 0
|
|
438
|
+
const r2 = runSetup(setupRoot, args);
|
|
439
|
+
assert.strictEqual(r2.code, 0, `second run should succeed, got ${r2.code}; stderr=${r2.stderr}`);
|
|
440
|
+
assert.ok(/no changes|already configured/i.test(`${r2.stdout}\n${r2.stderr}`),
|
|
441
|
+
'second run with same flags should report "no changes"');
|
|
442
|
+
|
|
443
|
+
// Config is still correct
|
|
444
|
+
const cfg2 = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
445
|
+
assert.strictEqual(cfg2.memoryRepo.remote, remote, 'remote should still match');
|
|
446
|
+
assert.strictEqual(cfg2.gitRemote, remote, 'top-level gitRemote should still match');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('reconfiguring an existing vault with a new remote updates both fields', () => {
|
|
450
|
+
// Bootstrap with one remote
|
|
451
|
+
const oldRemote = `https://github.com/example-org/setup-${Date.now()}.git`;
|
|
452
|
+
const r1 = runSetup(setupRoot, [
|
|
453
|
+
'--non-interactive',
|
|
454
|
+
'--mode', 'managed',
|
|
455
|
+
'--repo-name', `reconfig-${Date.now()}`,
|
|
456
|
+
'--remote', oldRemote,
|
|
457
|
+
]);
|
|
458
|
+
assert.strictEqual(r1.code, 0, `bootstrap should succeed, got ${r1.code}; stderr=${r1.stderr}`);
|
|
459
|
+
|
|
460
|
+
// Now reconfigure with a new remote
|
|
461
|
+
const newRemote = `git@github.com:example-org/setup-${Date.now()}-new.git`;
|
|
462
|
+
const r2 = runSetup(setupRoot, [
|
|
463
|
+
'--non-interactive',
|
|
464
|
+
'--mode', 'managed',
|
|
465
|
+
'--remote', newRemote,
|
|
466
|
+
]);
|
|
467
|
+
assert.strictEqual(r2.code, 0, `reconfigure should succeed, got ${r2.code}; stderr=${r2.stderr}; stdout=${r2.stdout}`);
|
|
468
|
+
const cfgPath = join(setupRoot, '.bizar', 'memory.json');
|
|
469
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
470
|
+
assert.strictEqual(cfg.memoryRepo.remote, newRemote,
|
|
471
|
+
`memoryRepo.remote should be updated to ${newRemote}, got ${cfg.memoryRepo.remote}`);
|
|
472
|
+
assert.strictEqual(cfg.gitRemote, newRemote,
|
|
473
|
+
`gitRemote should be updated to ${newRemote}, got ${cfg.gitRemote}`);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// ─── validateRemoteUrl (pure unit tests via the CLI's exposed export) ───────
|
|
478
|
+
// We spawn a tiny node script that imports the helper to avoid pulling in the
|
|
479
|
+
// chalk-bound runMemory entry point.
|
|
480
|
+
|
|
481
|
+
describe('validateRemoteUrl', () => {
|
|
482
|
+
// Run the helper through `node --input-type=module -e <code>` so we test
|
|
483
|
+
// the exact exported implementation, not a copy.
|
|
484
|
+
function callValidate(url) {
|
|
485
|
+
const code = `
|
|
486
|
+
import { validateRemoteUrl } from ${JSON.stringify(join(__dirname, '..', '..', 'cli', 'memory.mjs'))};
|
|
487
|
+
const r = validateRemoteUrl(${JSON.stringify(url)});
|
|
488
|
+
process.stdout.write(JSON.stringify(r));
|
|
489
|
+
`;
|
|
490
|
+
try {
|
|
491
|
+
const stdout = execFileSync('node', ['--input-type=module', '-e', code], {
|
|
492
|
+
encoding: 'utf8',
|
|
493
|
+
stdio: 'pipe',
|
|
494
|
+
timeout: 15_000,
|
|
495
|
+
});
|
|
496
|
+
return { ok: true, result: JSON.parse(stdout) };
|
|
497
|
+
} catch (err) {
|
|
498
|
+
return { ok: false, error: err.message };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
test('accepts git@host:path (scp-style SSH)', () => {
|
|
503
|
+
const r = callValidate('git@github.com:user/repo.git');
|
|
504
|
+
assert.deepStrictEqual(r, { ok: true, result: { valid: true, kind: 'ssh' } });
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test('accepts ssh://git@host/path (SSH URL form)', () => {
|
|
508
|
+
const r = callValidate('ssh://git@github.com/user/repo.git');
|
|
509
|
+
assert.deepStrictEqual(r, { ok: true, result: { valid: true, kind: 'ssh-url' } });
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test('accepts ssh:// without explicit user', () => {
|
|
513
|
+
const r = callValidate('ssh://github.com/user/repo.git');
|
|
514
|
+
assert.deepStrictEqual(r, { ok: true, result: { valid: true, kind: 'ssh-url' } });
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test('accepts https://host/path', () => {
|
|
518
|
+
const r = callValidate('https://github.com/user/repo.git');
|
|
519
|
+
assert.deepStrictEqual(r, { ok: true, result: { valid: true, kind: 'https' } });
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test('rejects file:// URLs', () => {
|
|
523
|
+
const r = callValidate('file:///tmp/repo');
|
|
524
|
+
assert.ok(r.ok && r.result.valid === false, `expected invalid, got ${JSON.stringify(r)}`);
|
|
525
|
+
assert.ok(typeof r.result.error === 'string' && r.result.error.length > 0);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('rejects plain filesystem paths', () => {
|
|
529
|
+
const r = callValidate('/home/user/repos/my-vault');
|
|
530
|
+
assert.ok(r.ok && r.result.valid === false);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('rejects empty string', () => {
|
|
534
|
+
const r = callValidate('');
|
|
535
|
+
assert.ok(r.ok && r.result.valid === false, 'empty string should be invalid');
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test('trims whitespace before validating', () => {
|
|
539
|
+
const r = callValidate(' git@github.com:user/repo.git ');
|
|
540
|
+
assert.deepStrictEqual(r, { ok: true, result: { valid: true, kind: 'ssh' } });
|
|
541
|
+
});
|
|
542
|
+
});
|