@onebrain-ai/cli 2.0.1 → 2.0.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.
@@ -1,419 +0,0 @@
1
- /**
2
- * vault-sync integration tests (TDD)
3
- *
4
- * Uses a mock GitHub tarball — does NOT hit real GitHub.
5
- * Verifies all 7 steps with a temp vault dir.
6
- */
7
-
8
- import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
9
- import { spawnSync } from 'node:child_process';
10
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
- import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
12
- import { tmpdir } from 'node:os';
13
- import { join } from 'node:path';
14
-
15
- import { type VaultSyncOptions, runVaultSync } from './vault-sync.js';
16
-
17
- // ---------------------------------------------------------------------------
18
- // Tarball builder helpers (uses tar CLI — available on macOS/Linux)
19
- // ---------------------------------------------------------------------------
20
-
21
- interface TarballOpts {
22
- prefix?: string;
23
- pluginVersion?: string;
24
- extraPluginFiles?: Record<string, string>;
25
- claudeMdContent?: string;
26
- geminiMdContent?: string;
27
- agentsMdContent?: string;
28
- }
29
-
30
- /**
31
- * Build a minimal fake tarball (.tar.gz) using the tar CLI.
32
- * Layout mirrors a real GitHub archive:
33
- * kengio-onebrain-<sha>/
34
- * .claude/plugins/onebrain/.claude-plugin/plugin.json
35
- * .claude/plugins/onebrain/INSTRUCTIONS.md
36
- * .claude/plugins/onebrain/skills/example/SKILL.md
37
- * README.md CONTRIBUTING.md CHANGELOG.md
38
- * CLAUDE.md GEMINI.md AGENTS.md
39
- */
40
- function buildMockTarball(opts: TarballOpts = {}): Buffer {
41
- const prefix = opts.prefix ?? 'kengio-onebrain-abc1234';
42
- const version = opts.pluginVersion ?? '1.11.0';
43
-
44
- const files: Record<string, string> = {
45
- [`${prefix}/.claude/plugins/onebrain/.claude-plugin/plugin.json`]: JSON.stringify({
46
- id: 'onebrain',
47
- version,
48
- name: 'OneBrain',
49
- }),
50
- [`${prefix}/.claude/plugins/onebrain/INSTRUCTIONS.md`]: '# OneBrain Instructions\n',
51
- [`${prefix}/.claude/plugins/onebrain/skills/example/SKILL.md`]: '# Example Skill\n',
52
- [`${prefix}/README.md`]: '# OneBrain\n',
53
- [`${prefix}/CONTRIBUTING.md`]: '# Contributing\n',
54
- [`${prefix}/CHANGELOG.md`]: '# Changelog\n',
55
- [`${prefix}/CLAUDE.md`]: opts.claudeMdContent ?? '@.claude/plugins/onebrain/INSTRUCTIONS.md\n',
56
- [`${prefix}/GEMINI.md`]: opts.geminiMdContent ?? '@.claude/plugins/onebrain/INSTRUCTIONS.md\n',
57
- [`${prefix}/AGENTS.md`]: opts.agentsMdContent ?? '@.claude/plugins/onebrain/INSTRUCTIONS.md\n',
58
- ...Object.fromEntries(
59
- Object.entries(opts.extraPluginFiles ?? {}).map(([k, v]) => [
60
- `${prefix}/.claude/plugins/onebrain/${k}`,
61
- v,
62
- ]),
63
- ),
64
- };
65
-
66
- // Write files to a temp staging directory
67
- const stageDir = `${tmpdir()}/onebrain-stage-${process.pid}`;
68
- mkdirSync(stageDir, { recursive: true });
69
-
70
- for (const [relPath, content] of Object.entries(files)) {
71
- const fullPath = join(stageDir, relPath);
72
- mkdirSync(join(fullPath, '..'), { recursive: true });
73
- writeFileSync(fullPath, content, 'utf8');
74
- }
75
-
76
- const tarPath = join(stageDir, 'bundle.tar.gz');
77
- const r = spawnSync('tar', ['-czf', tarPath, '-C', stageDir, prefix], { encoding: 'utf8' });
78
- if (r.status !== 0) {
79
- throw new Error(`tar failed: ${r.stderr}`);
80
- }
81
-
82
- const buf = readFileSync(tarPath);
83
- rmSync(stageDir, { recursive: true, force: true });
84
- return buf as Buffer;
85
- }
86
-
87
- /**
88
- * Create a mock fetch that returns the tarball buffer as a Response.
89
- */
90
- function mockFetchWithTarball(tarball: Buffer): VaultSyncOptions['fetchFn'] {
91
- return async (_url: string | URL | Request) => {
92
- return new Response(tarball, {
93
- status: 200,
94
- headers: { 'content-type': 'application/x-gzip' },
95
- });
96
- };
97
- }
98
-
99
- // ---------------------------------------------------------------------------
100
- // Test vault setup
101
- // ---------------------------------------------------------------------------
102
-
103
- async function makeVaultDir(): Promise<string> {
104
- return mkdtemp(join(tmpdir(), 'onebrain-vs-test-'));
105
- }
106
-
107
- const VALID_VAULT_YML =
108
- 'method: onebrain\nupdate_channel: stable\nfolders:\n inbox: 00-inbox\n logs: 07-logs\n';
109
-
110
- // ---------------------------------------------------------------------------
111
- // Tests
112
- // ---------------------------------------------------------------------------
113
-
114
- describe('runVaultSync', () => {
115
- let vaultDir: string;
116
- let tarball: Buffer;
117
-
118
- beforeEach(async () => {
119
- vaultDir = await makeVaultDir();
120
- await writeFile(join(vaultDir, 'vault.yml'), VALID_VAULT_YML, 'utf8');
121
- tarball = buildMockTarball({});
122
- });
123
-
124
- afterEach(async () => {
125
- await rm(vaultDir, { recursive: true, force: true });
126
- });
127
-
128
- // ── Test 1: fresh sync ─────────────────────────────────────────────────
129
-
130
- it('fresh sync: syncs all plugin files and root docs', async () => {
131
- const result = await runVaultSync(vaultDir, {
132
- fetchFn: mockFetchWithTarball(tarball),
133
- });
134
-
135
- expect(result.ok).toBe(true);
136
- expect(result.filesAdded).toBeGreaterThan(0);
137
- expect(result.filesRemoved).toBe(0);
138
-
139
- // plugin.json should be present (lives under .claude-plugin/ within the plugin dir)
140
- const pluginJson = join(vaultDir, '.claude/plugins/onebrain/.claude-plugin/plugin.json');
141
- const pj = JSON.parse(await readFile(pluginJson, 'utf8'));
142
- expect(pj.version).toBe('1.11.0');
143
-
144
- // Root docs should be present
145
- const readme = await readFile(join(vaultDir, 'README.md'), 'utf8');
146
- expect(readme).toContain('# OneBrain');
147
-
148
- // vault.yml should have onebrain_version
149
- const vaultYml = await readFile(join(vaultDir, 'vault.yml'), 'utf8');
150
- expect(vaultYml).toContain('onebrain_version: 1.11.0');
151
- });
152
-
153
- // ── Test 2: stale file removal ──────────────────────────────────────────
154
-
155
- it('sync with stale files: removes files not in tarball', async () => {
156
- // Pre-populate vault plugin dir with a stale file not in tarball
157
- const pluginDir = join(vaultDir, '.claude/plugins/onebrain');
158
- await mkdir(pluginDir, { recursive: true });
159
- await writeFile(join(pluginDir, 'stale-file.md'), '# Stale\n', 'utf8');
160
-
161
- const result = await runVaultSync(vaultDir, {
162
- fetchFn: mockFetchWithTarball(tarball),
163
- });
164
-
165
- expect(result.ok).toBe(true);
166
- expect(result.filesRemoved).toBeGreaterThanOrEqual(1);
167
-
168
- // stale file should be gone
169
- let staleExists = false;
170
- try {
171
- await stat(join(pluginDir, 'stale-file.md'));
172
- staleExists = true;
173
- } catch {
174
- staleExists = false;
175
- }
176
- expect(staleExists).toBe(false);
177
- });
178
-
179
- // ── Test 3: marketplace pin skip ────────────────────────────────────────
180
-
181
- it('source: marketplace → pin step is skipped', async () => {
182
- const pluginsDir = join(vaultDir, '.fake-claude-plugins');
183
- await mkdir(pluginsDir, { recursive: true });
184
- const installedJson = {
185
- plugins: {
186
- 'onebrain@marketplace': [
187
- {
188
- id: 'onebrain',
189
- source: 'marketplace',
190
- installPath: join(pluginsDir, 'cache/marketplace/onebrain/1.10.0'),
191
- },
192
- ],
193
- },
194
- };
195
- const installedPath = join(pluginsDir, 'installed_plugins.json');
196
- await writeFile(installedPath, JSON.stringify(installedJson, null, 2), 'utf8');
197
-
198
- const result = await runVaultSync(vaultDir, {
199
- fetchFn: mockFetchWithTarball(tarball),
200
- installedPluginsPath: installedPath,
201
- });
202
-
203
- expect(result.ok).toBe(true);
204
- expect(result.pinSkipped).toBe(true);
205
-
206
- // installed_plugins.json installPath must NOT be rewritten
207
- const afterJson = JSON.parse(await readFile(installedPath, 'utf8'));
208
- const entry = afterJson.plugins['onebrain@marketplace'][0];
209
- expect(entry.installPath).toContain('cache/marketplace/onebrain/1.10.0');
210
- });
211
-
212
- // ── Test 4: harness file import injection ───────────────────────────────
213
-
214
- it('injects new @import lines not already in vault harness file', async () => {
215
- // Vault CLAUDE.md has existing import + custom content
216
- await writeFile(
217
- join(vaultDir, 'CLAUDE.md'),
218
- '# My Config\n\n@.claude/plugins/onebrain/INSTRUCTIONS.md\n',
219
- 'utf8',
220
- );
221
-
222
- const tarballWithNewImport = buildMockTarball({
223
- claudeMdContent:
224
- '@.claude/plugins/onebrain/INSTRUCTIONS.md\n@.claude/plugins/onebrain/NEW.md\n',
225
- });
226
-
227
- const result = await runVaultSync(vaultDir, {
228
- fetchFn: mockFetchWithTarball(tarballWithNewImport),
229
- });
230
-
231
- expect(result.ok).toBe(true);
232
-
233
- const claude = await readFile(join(vaultDir, 'CLAUDE.md'), 'utf8');
234
- // New import added
235
- expect(claude).toContain('@.claude/plugins/onebrain/NEW.md');
236
- // Original vault content preserved
237
- expect(claude).toContain('# My Config');
238
- // No duplicate of the existing import
239
- const matches = claude.match(/@\.claude\/plugins\/onebrain\/INSTRUCTIONS\.md/g);
240
- expect(matches?.length).toBe(1);
241
-
242
- expect(result.importsAdded).toBeGreaterThan(0);
243
- });
244
-
245
- // ── Test 5: no duplicate imports injected ──────────────────────────────
246
-
247
- it('does not inject @import lines already in vault', async () => {
248
- // Pre-create all three harness files so no file triggers the "created fresh" path
249
- const existingImport = '@.claude/plugins/onebrain/INSTRUCTIONS.md\n';
250
- await writeFile(join(vaultDir, 'CLAUDE.md'), existingImport, 'utf8');
251
- await writeFile(join(vaultDir, 'GEMINI.md'), existingImport, 'utf8');
252
- await writeFile(join(vaultDir, 'AGENTS.md'), existingImport, 'utf8');
253
-
254
- const tarballSameImport = buildMockTarball({
255
- claudeMdContent: existingImport,
256
- geminiMdContent: existingImport,
257
- agentsMdContent: existingImport,
258
- });
259
-
260
- const result = await runVaultSync(vaultDir, {
261
- fetchFn: mockFetchWithTarball(tarballSameImport),
262
- });
263
-
264
- expect(result.ok).toBe(true);
265
-
266
- const claude = await readFile(join(vaultDir, 'CLAUDE.md'), 'utf8');
267
- const matches = claude.match(/@\.claude\/plugins\/onebrain\/INSTRUCTIONS\.md/g);
268
- expect(matches?.length).toBe(1);
269
- // All three harness files had identical imports — nothing new to inject
270
- expect(result.importsAdded).toBe(0);
271
- });
272
-
273
- // ── Test 6: vault.yml version + channel written ─────────────────────────
274
-
275
- it('writes onebrain_version and preserves update_channel in vault.yml', async () => {
276
- const result = await runVaultSync(vaultDir, {
277
- fetchFn: mockFetchWithTarball(tarball),
278
- });
279
-
280
- expect(result.ok).toBe(true);
281
-
282
- const vaultYml = await readFile(join(vaultDir, 'vault.yml'), 'utf8');
283
- expect(vaultYml).toContain('onebrain_version: 1.11.0');
284
- expect(vaultYml).toContain('update_channel: stable');
285
- });
286
-
287
- // ── Test 7: missing installed_plugins.json → pin no-op ─────────────────
288
-
289
- it('pin step is no-op when installed_plugins.json does not exist', async () => {
290
- const result = await runVaultSync(vaultDir, {
291
- fetchFn: mockFetchWithTarball(tarball),
292
- installedPluginsPath: join(vaultDir, 'nonexistent-installed_plugins.json'),
293
- });
294
-
295
- expect(result.ok).toBe(true);
296
- expect(result.pinSkipped).toBe(true);
297
- });
298
-
299
- // ── Test 8: pin updates installPath when not marketplace ───────────────
300
-
301
- it('pin updates installPath for non-marketplace entry inside cache dir', async () => {
302
- const pluginsDir = join(vaultDir, '.fake-claude-plugins');
303
- const cacheDir = join(pluginsDir, 'cache');
304
- const entryPath = join(cacheDir, 'onebrain-local', 'onebrain', '1.10.0');
305
- await mkdir(entryPath, { recursive: true });
306
-
307
- const installedJson = {
308
- plugins: {
309
- 'onebrain@onebrain-local': [
310
- {
311
- id: 'onebrain',
312
- source: 'local',
313
- installPath: entryPath,
314
- },
315
- ],
316
- },
317
- };
318
- const installedPath = join(pluginsDir, 'installed_plugins.json');
319
- await writeFile(installedPath, JSON.stringify(installedJson, null, 2), 'utf8');
320
-
321
- const result = await runVaultSync(vaultDir, {
322
- fetchFn: mockFetchWithTarball(tarball),
323
- installedPluginsPath: installedPath,
324
- installedPluginsCacheDir: cacheDir,
325
- });
326
-
327
- expect(result.ok).toBe(true);
328
- expect(result.pinSkipped).toBe(false);
329
-
330
- const afterJson = JSON.parse(await readFile(installedPath, 'utf8'));
331
- const entry = afterJson.plugins['onebrain@onebrain-local'][0];
332
- expect(entry.installPath).toBe(join(vaultDir, '.claude/plugins/onebrain'));
333
- });
334
-
335
- // ── Test 9: steps 6–7 errors are non-fatal ─────────────────────────────
336
-
337
- it('pin/cache errors are non-fatal — result.ok remains true', async () => {
338
- // vault.yml is valid YAML but not valid JSON → will cause JSON parse error in pin step
339
- const result = await runVaultSync(vaultDir, {
340
- fetchFn: mockFetchWithTarball(tarball),
341
- installedPluginsPath: join(vaultDir, 'vault.yml'),
342
- });
343
-
344
- expect(result.ok).toBe(true);
345
- });
346
-
347
- // ── Test 10: fetch failure → result.ok is false ────────────────────────
348
-
349
- it('download failure → result.ok is false', async () => {
350
- const vaultDir2 = await mkdtemp(join(tmpdir(), 'vs-fail-'));
351
- await writeFile(join(vaultDir2, 'vault.yml'), 'update_channel: stable\n');
352
-
353
- const result = await runVaultSync(vaultDir2, {
354
- fetchFn: async () => new Response('Not Found', { status: 404 }),
355
- });
356
-
357
- expect(result.ok).toBe(false);
358
-
359
- await rm(vaultDir2, { recursive: true });
360
- });
361
-
362
- // ── Test 11: corrupted tarball → result.ok false, error defined ────────
363
-
364
- it('corrupted tarball → result.ok is false, result.error defined', async () => {
365
- const result = await runVaultSync(vaultDir, {
366
- fetchFn: async () =>
367
- new Response(new Uint8Array([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe]).buffer, {
368
- status: 200,
369
- headers: { 'content-type': 'application/x-gzip' },
370
- }),
371
- });
372
-
373
- expect(result.ok).toBe(false);
374
- expect(result.error).toBeDefined();
375
- });
376
-
377
- // ── Test 12: HTTP 403 → result.ok false, error contains '403' ──────────
378
-
379
- it('HTTP 403 response → result.ok is false, error contains 403', async () => {
380
- const result = await runVaultSync(vaultDir, {
381
- fetchFn: async () => new Response('Forbidden', { status: 403 }),
382
- });
383
-
384
- expect(result.ok).toBe(false);
385
- expect(result.error).toBeDefined();
386
- expect(result.error).toContain('403');
387
- });
388
-
389
- // ── Test 13: filesRemoved counts actual deletions only ─────────────────
390
-
391
- it('filesRemoved counts actual deletions: unlinkFn throws for one of 2 stale files → filesRemoved === 1', async () => {
392
- // Pre-populate vault plugin dir with 2 stale files not in tarball
393
- const pluginDir = join(vaultDir, '.claude/plugins/onebrain');
394
- await mkdir(pluginDir, { recursive: true });
395
- await writeFile(join(pluginDir, 'stale-a.md'), '# Stale A\n', 'utf8');
396
- await writeFile(join(pluginDir, 'stale-b.md'), '# Stale B\n', 'utf8');
397
-
398
- let _callCount = 0;
399
- const partialUnlink: typeof import('node:fs/promises').unlink = async (path) => {
400
- _callCount++;
401
- if (String(path).endsWith('stale-a.md')) {
402
- const err = new Error('Permission denied') as NodeJS.ErrnoException;
403
- err.code = 'EACCES';
404
- throw err;
405
- }
406
- const { unlink: realUnlink } = await import('node:fs/promises');
407
- return realUnlink(path as string);
408
- };
409
-
410
- const result = await runVaultSync(vaultDir, {
411
- fetchFn: mockFetchWithTarball(tarball),
412
- unlinkFn: partialUnlink,
413
- });
414
-
415
- expect(result.ok).toBe(true);
416
- // Only 1 of the 2 stale files was actually deleted
417
- expect(result.filesRemoved).toBe(1);
418
- });
419
- });