@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.
- package/package.json +9 -7
- package/src/internal.ts +48 -1
- package/src/store/__tests__/backup-crypto.test.ts +101 -0
- package/src/store/__tests__/backup-pack.test.ts +491 -0
- package/src/store/__tests__/backup-unpack.test.ts +298 -0
- package/src/store/__tests__/regenerators.test.ts +234 -0
- package/src/store/__tests__/restore-conflict-report.test.ts +274 -0
- package/src/store/__tests__/restore-json-merge.test.ts +521 -0
- package/src/store/__tests__/t310-readiness.test.ts +111 -0
- package/src/store/__tests__/t311-integration.test.ts +661 -0
- package/src/store/backup-crypto.ts +209 -0
- package/src/store/backup-pack.ts +739 -0
- package/src/store/backup-unpack.ts +583 -0
- package/src/store/regenerators.ts +243 -0
- package/src/store/restore-conflict-report.ts +317 -0
- package/src/store/restore-json-merge.ts +653 -0
- package/src/store/t310-readiness.ts +119 -0
|
@@ -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
|
+
});
|