@cleocode/core 2026.4.12 → 2026.4.13

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.
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Tests for backup-pack.ts (T347).
3
+ *
4
+ * Covers: bundle creation, manifest.json as first tar entry, manifest field
5
+ * correctness, checksums.sha256 coverage, database SHA-256 + size entries,
6
+ * encrypted bundle magic header, validation errors, and scope filtering.
7
+ *
8
+ * Uses real node:sqlite DatabaseSync to seed minimal test databases.
9
+ * All filesystem interactions occur in temp directories; the real user's
10
+ * project root is never touched.
11
+ *
12
+ * @task T347
13
+ * @epic T311
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import { createRequire } from 'node:module';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
21
+ import { extract as tarExtract, list as tarList } from 'tar';
22
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
23
+ import { packBundle } from '../backup-pack.js';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // node:sqlite interop
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const _require = createRequire(import.meta.url);
30
+ type DatabaseSync = _DatabaseSyncType;
31
+ const { DatabaseSync } = _require('node:sqlite') as {
32
+ DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
33
+ };
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Create a minimal SQLite database with one table and one row at the given path.
41
+ */
42
+ function createMinimalDb(dbPath: string): void {
43
+ const db = new DatabaseSync(dbPath);
44
+ db.exec('CREATE TABLE t (x INTEGER); INSERT INTO t VALUES (1);');
45
+ db.close();
46
+ }
47
+
48
+ /**
49
+ * Seed a project root with the minimal .cleo layout required by packBundle.
50
+ * Creates: tasks.db, brain.db, conduit.db, config.json, project-info.json,
51
+ * project-context.json.
52
+ */
53
+ function seedProject(projectRoot: string): void {
54
+ const cleoDir = path.join(projectRoot, '.cleo');
55
+ fs.mkdirSync(cleoDir, { recursive: true });
56
+ for (const name of ['tasks', 'brain', 'conduit']) {
57
+ createMinimalDb(path.join(cleoDir, `${name}.db`));
58
+ }
59
+ fs.writeFileSync(path.join(cleoDir, 'config.json'), JSON.stringify({ projectRoot }));
60
+ fs.writeFileSync(
61
+ path.join(cleoDir, 'project-info.json'),
62
+ JSON.stringify({ name: 'test-project' }),
63
+ );
64
+ fs.writeFileSync(path.join(cleoDir, 'project-context.json'), JSON.stringify({ env: 'test' }));
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Test suite
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe('T347 backup-pack', () => {
72
+ let tmpRoot: string;
73
+ let outputDir: string;
74
+
75
+ beforeEach(() => {
76
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cleo-t347-root-'));
77
+ outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cleo-t347-out-'));
78
+ seedProject(tmpRoot);
79
+ });
80
+
81
+ afterEach(() => {
82
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
83
+ fs.rmSync(outputDir, { recursive: true, force: true });
84
+ });
85
+
86
+ // -------------------------------------------------------------------------
87
+ // Basic bundle creation
88
+ // -------------------------------------------------------------------------
89
+
90
+ it('creates a project-scope bundle at the output path', async () => {
91
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
92
+ const result = await packBundle({
93
+ scope: 'project',
94
+ projectRoot: tmpRoot,
95
+ outputPath: bundlePath,
96
+ });
97
+ expect(result.bundlePath).toBe(bundlePath);
98
+ expect(fs.existsSync(bundlePath)).toBe(true);
99
+ expect(result.size).toBeGreaterThan(0);
100
+ });
101
+
102
+ it('result.size matches the actual file size on disk', async () => {
103
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
104
+ const result = await packBundle({
105
+ scope: 'project',
106
+ projectRoot: tmpRoot,
107
+ outputPath: bundlePath,
108
+ });
109
+ const diskSize = fs.statSync(bundlePath).size;
110
+ expect(result.size).toBe(diskSize);
111
+ });
112
+
113
+ // -------------------------------------------------------------------------
114
+ // Archive structure
115
+ // -------------------------------------------------------------------------
116
+
117
+ it('bundle contains manifest.json, schemas/, databases/, json/, checksums.sha256', async () => {
118
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
119
+ await packBundle({ scope: 'project', projectRoot: tmpRoot, outputPath: bundlePath });
120
+
121
+ const entries: string[] = [];
122
+ await tarList({
123
+ file: bundlePath,
124
+ onReadEntry: (entry) => entries.push(entry.path),
125
+ });
126
+
127
+ expect(entries).toContain('manifest.json');
128
+ expect(entries.some((e) => e.startsWith('schemas/'))).toBe(true);
129
+ expect(entries.some((e) => e.startsWith('databases/'))).toBe(true);
130
+ expect(entries.some((e) => e.startsWith('json/'))).toBe(true);
131
+ expect(entries).toContain('checksums.sha256');
132
+ });
133
+
134
+ it('manifest.json is the first tar entry', async () => {
135
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
136
+ await packBundle({ scope: 'project', projectRoot: tmpRoot, outputPath: bundlePath });
137
+
138
+ const entries: string[] = [];
139
+ await tarList({
140
+ file: bundlePath,
141
+ onReadEntry: (entry) => entries.push(entry.path),
142
+ });
143
+ expect(entries[0]).toBe('manifest.json');
144
+ });
145
+
146
+ // -------------------------------------------------------------------------
147
+ // Manifest field correctness
148
+ // -------------------------------------------------------------------------
149
+
150
+ it('manifest.backup.scope matches input scope', async () => {
151
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
152
+ const result = await packBundle({
153
+ scope: 'project',
154
+ projectRoot: tmpRoot,
155
+ outputPath: bundlePath,
156
+ });
157
+ expect(result.manifest.backup.scope).toBe('project');
158
+ });
159
+
160
+ it('manifest.$schema is "./schemas/manifest-v1.json"', async () => {
161
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
162
+ const result = await packBundle({
163
+ scope: 'project',
164
+ projectRoot: tmpRoot,
165
+ outputPath: bundlePath,
166
+ });
167
+ expect(result.manifest.$schema).toBe('./schemas/manifest-v1.json');
168
+ });
169
+
170
+ it('manifest.manifestVersion is "1.0.0"', async () => {
171
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
172
+ const result = await packBundle({
173
+ scope: 'project',
174
+ projectRoot: tmpRoot,
175
+ outputPath: bundlePath,
176
+ });
177
+ expect(result.manifest.manifestVersion).toBe('1.0.0');
178
+ });
179
+
180
+ it('manifest.integrity.algorithm is "sha256"', async () => {
181
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
182
+ const result = await packBundle({
183
+ scope: 'project',
184
+ projectRoot: tmpRoot,
185
+ outputPath: bundlePath,
186
+ });
187
+ expect(result.manifest.integrity.algorithm).toBe('sha256');
188
+ });
189
+
190
+ it('manifest.integrity.checksumsFile is "checksums.sha256"', async () => {
191
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
192
+ const result = await packBundle({
193
+ scope: 'project',
194
+ projectRoot: tmpRoot,
195
+ outputPath: bundlePath,
196
+ });
197
+ expect(result.manifest.integrity.checksumsFile).toBe('checksums.sha256');
198
+ });
199
+
200
+ it('manifest.integrity.manifestHash is a 64-char hex string', async () => {
201
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
202
+ const result = await packBundle({
203
+ scope: 'project',
204
+ projectRoot: tmpRoot,
205
+ outputPath: bundlePath,
206
+ });
207
+ expect(result.manifest.integrity.manifestHash).toMatch(/^[a-f0-9]{64}$/);
208
+ });
209
+
210
+ it('manifest.backup.encrypted is false when not encrypting', async () => {
211
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
212
+ const result = await packBundle({
213
+ scope: 'project',
214
+ projectRoot: tmpRoot,
215
+ outputPath: bundlePath,
216
+ });
217
+ expect(result.manifest.backup.encrypted).toBe(false);
218
+ });
219
+
220
+ it('manifest.backup.encrypted is true when encrypting', async () => {
221
+ const bundlePath = path.join(outputDir, 'test.enc.cleobundle.tar.gz');
222
+ const result = await packBundle({
223
+ scope: 'project',
224
+ projectRoot: tmpRoot,
225
+ outputPath: bundlePath,
226
+ encrypt: true,
227
+ passphrase: 'test-pass',
228
+ });
229
+ expect(result.manifest.backup.encrypted).toBe(true);
230
+ });
231
+
232
+ it('manifest.backup.projectName is set when provided', async () => {
233
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
234
+ const result = await packBundle({
235
+ scope: 'project',
236
+ projectRoot: tmpRoot,
237
+ outputPath: bundlePath,
238
+ projectName: 'my-test-project',
239
+ });
240
+ expect(result.manifest.backup.projectName).toBe('my-test-project');
241
+ });
242
+
243
+ it('manifest.backup.machineFingerprint is a 64-char hex string', async () => {
244
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
245
+ const result = await packBundle({
246
+ scope: 'project',
247
+ projectRoot: tmpRoot,
248
+ outputPath: bundlePath,
249
+ });
250
+ expect(result.manifest.backup.machineFingerprint).toMatch(/^[a-f0-9]{64}$/);
251
+ });
252
+
253
+ it('manifest.backup.projectFingerprint is a 64-char hex for project scope', async () => {
254
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
255
+ const result = await packBundle({
256
+ scope: 'project',
257
+ projectRoot: tmpRoot,
258
+ outputPath: bundlePath,
259
+ });
260
+ expect(result.manifest.backup.projectFingerprint).toMatch(/^[a-f0-9]{64}$/);
261
+ });
262
+
263
+ // -------------------------------------------------------------------------
264
+ // Database manifest entries
265
+ // -------------------------------------------------------------------------
266
+
267
+ it('manifest.databases has at least 3 entries for project scope', async () => {
268
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
269
+ const result = await packBundle({
270
+ scope: 'project',
271
+ projectRoot: tmpRoot,
272
+ outputPath: bundlePath,
273
+ });
274
+ expect(result.manifest.databases.length).toBeGreaterThanOrEqual(3);
275
+ });
276
+
277
+ it('manifest.databases entries each have a valid sha256 and positive size', async () => {
278
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
279
+ const result = await packBundle({
280
+ scope: 'project',
281
+ projectRoot: tmpRoot,
282
+ outputPath: bundlePath,
283
+ });
284
+ for (const entry of result.manifest.databases) {
285
+ expect(entry.sha256).toMatch(/^[a-f0-9]{64}$/);
286
+ expect(entry.size).toBeGreaterThan(0);
287
+ }
288
+ });
289
+
290
+ it('manifest.databases entries include tasks, brain, conduit for project scope', async () => {
291
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
292
+ const result = await packBundle({
293
+ scope: 'project',
294
+ projectRoot: tmpRoot,
295
+ outputPath: bundlePath,
296
+ });
297
+ const names = result.manifest.databases.map((d) => d.name);
298
+ expect(names).toContain('tasks');
299
+ expect(names).toContain('brain');
300
+ expect(names).toContain('conduit');
301
+ });
302
+
303
+ it('manifest.databases entries include rowCounts', async () => {
304
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
305
+ const result = await packBundle({
306
+ scope: 'project',
307
+ projectRoot: tmpRoot,
308
+ outputPath: bundlePath,
309
+ });
310
+ for (const entry of result.manifest.databases) {
311
+ expect(entry.rowCounts).toBeDefined();
312
+ }
313
+ });
314
+
315
+ // -------------------------------------------------------------------------
316
+ // JSON manifest entries
317
+ // -------------------------------------------------------------------------
318
+
319
+ it('manifest.json entries include config.json, project-info.json, project-context.json', async () => {
320
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
321
+ const result = await packBundle({
322
+ scope: 'project',
323
+ projectRoot: tmpRoot,
324
+ outputPath: bundlePath,
325
+ });
326
+ const filenames = result.manifest.json.map((j) => j.filename);
327
+ expect(filenames).toContain('json/config.json');
328
+ expect(filenames).toContain('json/project-info.json');
329
+ expect(filenames).toContain('json/project-context.json');
330
+ });
331
+
332
+ // -------------------------------------------------------------------------
333
+ // Checksums file
334
+ // -------------------------------------------------------------------------
335
+
336
+ it('checksums.sha256 contains entries for databases and json files', async () => {
337
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
338
+ await packBundle({ scope: 'project', projectRoot: tmpRoot, outputPath: bundlePath });
339
+
340
+ const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cleo-t347-extract-'));
341
+ try {
342
+ await tarExtract({ file: bundlePath, cwd: extractDir });
343
+ const checksumContent = fs.readFileSync(path.join(extractDir, 'checksums.sha256'), 'utf-8');
344
+ expect(checksumContent).toMatch(/databases\/tasks\.db/);
345
+ expect(checksumContent).toMatch(/databases\/brain\.db/);
346
+ expect(checksumContent).toMatch(/databases\/conduit\.db/);
347
+ expect(checksumContent).toMatch(/json\/config\.json/);
348
+ expect(checksumContent).toMatch(/json\/project-info\.json/);
349
+ expect(checksumContent).toMatch(/json\/project-context\.json/);
350
+ } finally {
351
+ fs.rmSync(extractDir, { recursive: true, force: true });
352
+ }
353
+ });
354
+
355
+ it('checksums.sha256 does NOT contain an entry for manifest.json', async () => {
356
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
357
+ await packBundle({ scope: 'project', projectRoot: tmpRoot, outputPath: bundlePath });
358
+
359
+ const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cleo-t347-extract2-'));
360
+ try {
361
+ await tarExtract({ file: bundlePath, cwd: extractDir });
362
+ const checksumContent = fs.readFileSync(path.join(extractDir, 'checksums.sha256'), 'utf-8');
363
+ // manifest.json must NOT appear in checksums (covered by manifestHash)
364
+ expect(checksumContent).not.toMatch(/^[a-f0-9]{64} {2}manifest\.json/m);
365
+ } finally {
366
+ fs.rmSync(extractDir, { recursive: true, force: true });
367
+ }
368
+ });
369
+
370
+ it('checksums.sha256 entries use GNU format: "<hash> <path>"', async () => {
371
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
372
+ await packBundle({ scope: 'project', projectRoot: tmpRoot, outputPath: bundlePath });
373
+
374
+ const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cleo-t347-extract3-'));
375
+ try {
376
+ await tarExtract({ file: bundlePath, cwd: extractDir });
377
+ const checksumContent = fs.readFileSync(path.join(extractDir, 'checksums.sha256'), 'utf-8');
378
+ const lines = checksumContent.trim().split('\n');
379
+ // Every non-empty line must match: 64 hex chars + two spaces + relative path
380
+ for (const line of lines) {
381
+ if (line.trim().length === 0) continue;
382
+ expect(line).toMatch(/^[a-f0-9]{64} {2}\S+/);
383
+ }
384
+ } finally {
385
+ fs.rmSync(extractDir, { recursive: true, force: true });
386
+ }
387
+ });
388
+
389
+ // -------------------------------------------------------------------------
390
+ // Encryption
391
+ // -------------------------------------------------------------------------
392
+
393
+ it('encrypted bundle has magic header "CLEOENC1"', async () => {
394
+ const bundlePath = path.join(outputDir, 'test.enc.cleobundle.tar.gz');
395
+ await packBundle({
396
+ scope: 'project',
397
+ projectRoot: tmpRoot,
398
+ outputPath: bundlePath,
399
+ encrypt: true,
400
+ passphrase: 'test-phrase',
401
+ });
402
+ const header = fs.readFileSync(bundlePath).subarray(0, 8);
403
+ expect(header.toString('utf8')).toBe('CLEOENC1');
404
+ });
405
+
406
+ it('throws when encrypt=true without passphrase', async () => {
407
+ await expect(
408
+ packBundle({
409
+ scope: 'project',
410
+ projectRoot: tmpRoot,
411
+ outputPath: path.join(outputDir, 'x.enc.cleobundle.tar.gz'),
412
+ encrypt: true,
413
+ }),
414
+ ).rejects.toThrow(/passphrase/);
415
+ });
416
+
417
+ // -------------------------------------------------------------------------
418
+ // Validation errors
419
+ // -------------------------------------------------------------------------
420
+
421
+ it('throws when scope is "project" but projectRoot is not provided', async () => {
422
+ await expect(
423
+ packBundle({
424
+ scope: 'project',
425
+ outputPath: path.join(outputDir, 'x.cleobundle.tar.gz'),
426
+ }),
427
+ ).rejects.toThrow(/projectRoot/);
428
+ });
429
+
430
+ // -------------------------------------------------------------------------
431
+ // Staging cleanup
432
+ // -------------------------------------------------------------------------
433
+
434
+ it('cleans up the staging dir even on success', async () => {
435
+ // This test exercises the normal success path; we verify there are no
436
+ // stale cleo-pack-* directories in os.tmpdir() after the call.
437
+ const preExisting = fs.readdirSync(os.tmpdir()).filter((n) => n.startsWith('cleo-pack-'));
438
+
439
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
440
+ await packBundle({ scope: 'project', projectRoot: tmpRoot, outputPath: bundlePath });
441
+
442
+ const postCall = fs.readdirSync(os.tmpdir()).filter((n) => n.startsWith('cleo-pack-'));
443
+ // No new staging dirs should remain after a successful pack
444
+ const newDirs = postCall.filter((d) => !preExisting.includes(d));
445
+ expect(newDirs).toHaveLength(0);
446
+ });
447
+
448
+ // -------------------------------------------------------------------------
449
+ // Manifest JSON schema conformance (shape check)
450
+ // -------------------------------------------------------------------------
451
+
452
+ it('manifest conforms to expected top-level shape', async () => {
453
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
454
+ const result = await packBundle({
455
+ scope: 'project',
456
+ projectRoot: tmpRoot,
457
+ outputPath: bundlePath,
458
+ });
459
+ const m = result.manifest;
460
+ expect(m.$schema).toBe('./schemas/manifest-v1.json');
461
+ expect(m.manifestVersion).toBe('1.0.0');
462
+ expect(typeof m.backup.createdAt).toBe('string');
463
+ expect(typeof m.backup.createdBy).toBe('string');
464
+ expect(Array.isArray(m.databases)).toBe(true);
465
+ expect(Array.isArray(m.json)).toBe(true);
466
+ expect(typeof m.integrity).toBe('object');
467
+ expect(m.integrity.algorithm).toBe('sha256');
468
+ });
469
+
470
+ // -------------------------------------------------------------------------
471
+ // Missing source files — graceful skip
472
+ // -------------------------------------------------------------------------
473
+
474
+ it('skips missing conduit.db without throwing', async () => {
475
+ // Remove conduit.db to simulate a project where it was not created
476
+ const cleoDir = path.join(tmpRoot, '.cleo');
477
+ fs.unlinkSync(path.join(cleoDir, 'conduit.db'));
478
+
479
+ const bundlePath = path.join(outputDir, 'test.cleobundle.tar.gz');
480
+ // Should not throw — missing file is skipped with a warning
481
+ const result = await packBundle({
482
+ scope: 'project',
483
+ projectRoot: tmpRoot,
484
+ outputPath: bundlePath,
485
+ });
486
+ const names = result.manifest.databases.map((d) => d.name);
487
+ expect(names).not.toContain('conduit');
488
+ expect(names).toContain('tasks');
489
+ expect(names).toContain('brain');
490
+ });
491
+ });