@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,739 @@
1
+ /**
2
+ * backup-pack.ts — Bundle creation for .cleobundle.tar.gz portability.
3
+ *
4
+ * Implements the pack side of the T311 export/import lifecycle. Packs
5
+ * project and/or global CLEO databases plus JSON config files into a
6
+ * .cleobundle.tar.gz archive with a manifest, JSON Schema, per-file
7
+ * SHA-256 checksums, and optional AES-256-GCM encryption.
8
+ *
9
+ * Archive layout (§2 of T311 spec):
10
+ * manifest.json — FIRST entry (streaming inspect)
11
+ * schemas/manifest-v1.json — bundled JSON Schema
12
+ * databases/ — VACUUM INTO snapshots of in-scope DBs
13
+ * json/ — config.json, project-info.json, project-context.json
14
+ * global/ — global-salt (scope global|all)
15
+ * checksums.sha256 — GNU sha256sum format, covers all except manifest.json
16
+ *
17
+ * @task T347
18
+ * @epic T311
19
+ * @why ADR-038 — portable cross-machine backup. Packs project + global DBs
20
+ * into a .cleobundle.tar.gz with manifest, checksums, and optional encryption.
21
+ * @what Implements the pack side of the export/import lifecycle.
22
+ * @module store/backup-pack
23
+ */
24
+
25
+ import crypto from 'node:crypto';
26
+ import fs from 'node:fs';
27
+ import { createRequire } from 'node:module';
28
+ import os from 'node:os';
29
+ import path from 'node:path';
30
+ import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
31
+ import type { BackupManifest, BackupScope } from '@cleocode/contracts';
32
+ import { create as tarCreate } from 'tar';
33
+ import { getCleoHome, getProjectRoot } from '../paths.js';
34
+ import { encryptBundle } from './backup-crypto.js';
35
+ import { getConduitDbPath } from './conduit-sqlite.js';
36
+ import { getGlobalSaltPath } from './global-salt.js';
37
+ import { getNexusDbPath } from './nexus-sqlite.js';
38
+ import { getGlobalSignaldockDbPath } from './signaldock-sqlite.js';
39
+ import { assertT310Ready } from './t310-readiness.js';
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // node:sqlite interop (createRequire — Vitest strips `node:` prefix)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ const _require = createRequire(import.meta.url);
46
+ type DatabaseSync = _DatabaseSyncType;
47
+ const { DatabaseSync } = _require('node:sqlite') as {
48
+ DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
49
+ };
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Public types
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Input parameters for {@link packBundle}.
57
+ *
58
+ * @task T347
59
+ * @epic T311
60
+ */
61
+ export interface PackBundleInput {
62
+ /** Export scope — determines which tiers and files are included. */
63
+ scope: BackupScope;
64
+ /** Absolute path to the project root. Required for 'project' and 'all' scopes. */
65
+ projectRoot?: string;
66
+ /** Target bundle path, e.g. /tmp/myproject-20260408.cleobundle.tar.gz */
67
+ outputPath: string;
68
+ /** Enable AES-256-GCM encryption. Requires passphrase. */
69
+ encrypt?: boolean;
70
+ /** Required when encrypt=true. */
71
+ passphrase?: string;
72
+ /** Optional label written into manifest.backup.projectName. */
73
+ projectName?: string;
74
+ }
75
+
76
+ /**
77
+ * Result of a successful {@link packBundle} call.
78
+ *
79
+ * @task T347
80
+ * @epic T311
81
+ */
82
+ export interface PackBundleResult {
83
+ /** Absolute path to the written bundle file. */
84
+ bundlePath: string;
85
+ /** Byte size of the final bundle file on disk. */
86
+ size: number;
87
+ /** Fully-populated manifest that was written into the bundle. */
88
+ manifest: BackupManifest;
89
+ /** Number of data files staged (excludes manifest.json, checksums.sha256, and schema). */
90
+ fileCount: number;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Path to the bundled JSON Schema (shipped with @cleocode/contracts)
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /** Resolves the schemas directory inside @cleocode/contracts (compile-time helper). */
98
+ function resolveContractsSchemasDir(): string {
99
+ // Walk up from this file to find packages/contracts/schemas/manifest-v1.json.
100
+ // In the installed package the file lives at contracts/schemas/manifest-v1.json.
101
+ // In the monorepo it lives at packages/contracts/schemas/manifest-v1.json.
102
+ const candidates = [
103
+ // Monorepo: packages/core/src/store → packages/core/src → packages/core → packages → root
104
+ path.resolve(
105
+ path.dirname(import.meta.url.replace('file://', '')),
106
+ '..',
107
+ '..',
108
+ '..',
109
+ '..',
110
+ 'contracts',
111
+ 'schemas',
112
+ ),
113
+ // Installed: node_modules/@cleocode/contracts/schemas
114
+ path.resolve(
115
+ path.dirname(import.meta.url.replace('file://', '')),
116
+ '..',
117
+ '..',
118
+ '..',
119
+ '..',
120
+ 'node_modules',
121
+ '@cleocode',
122
+ 'contracts',
123
+ 'schemas',
124
+ ),
125
+ // Fallback: dist sibling
126
+ path.resolve(
127
+ path.dirname(import.meta.url.replace('file://', '')),
128
+ '..',
129
+ '..',
130
+ 'node_modules',
131
+ '@cleocode',
132
+ 'contracts',
133
+ 'schemas',
134
+ ),
135
+ ];
136
+ for (const candidate of candidates) {
137
+ const schemaFile = path.join(candidate, 'manifest-v1.json');
138
+ if (fs.existsSync(schemaFile)) {
139
+ return candidate;
140
+ }
141
+ }
142
+ throw new Error(
143
+ 'backup-pack: cannot locate schemas/manifest-v1.json in @cleocode/contracts. ' +
144
+ 'Ensure the package is built and installed.',
145
+ );
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Private helpers
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Compute the SHA-256 hex digest of a file on disk.
154
+ *
155
+ * @param filePath - Absolute path to the file to hash.
156
+ * @returns 64-character lowercase hex string.
157
+ */
158
+ function sha256OfFile(filePath: string): string {
159
+ const hash = crypto.createHash('sha256');
160
+ hash.update(fs.readFileSync(filePath));
161
+ return hash.digest('hex');
162
+ }
163
+
164
+ /**
165
+ * Compute SHA-256 of a buffer.
166
+ *
167
+ * @param buf - Buffer to hash.
168
+ * @returns 64-character lowercase hex string.
169
+ */
170
+ function sha256OfBuffer(buf: Buffer): string {
171
+ return crypto.createHash('sha256').update(buf).digest('hex');
172
+ }
173
+
174
+ /**
175
+ * Compute the machine fingerprint: SHA-256 of the machine-key file at
176
+ * `getCleoHome()/machine-key`. If the file does not exist, returns a
177
+ * zero-padded sentinel (64 zeros) without throwing.
178
+ *
179
+ * @returns 64-character lowercase hex string.
180
+ */
181
+ function sha256OfMachineKey(): string {
182
+ const keyPath = path.join(getCleoHome(), 'machine-key');
183
+ if (!fs.existsSync(keyPath)) {
184
+ return '0'.repeat(64);
185
+ }
186
+ return sha256OfFile(keyPath);
187
+ }
188
+
189
+ /**
190
+ * Read the CalVer version from the @cleocode/cleo package or the monorepo root.
191
+ *
192
+ * @returns Version string, e.g. "2026.4.13". Falls back to "unknown".
193
+ */
194
+ function readLocalCleoVersion(): string {
195
+ // Try resolving @cleocode/cleo package.json from this file
196
+ const candidates = [
197
+ path.resolve(
198
+ path.dirname(import.meta.url.replace('file://', '')),
199
+ '..',
200
+ '..',
201
+ '..',
202
+ '..',
203
+ 'cleo',
204
+ 'package.json',
205
+ ),
206
+ path.resolve(
207
+ path.dirname(import.meta.url.replace('file://', '')),
208
+ '..',
209
+ '..',
210
+ '..',
211
+ '..',
212
+ '..',
213
+ 'package.json',
214
+ ),
215
+ path.resolve(path.dirname(import.meta.url.replace('file://', '')), '..', '..', 'package.json'),
216
+ ];
217
+ for (const candidate of candidates) {
218
+ if (fs.existsSync(candidate)) {
219
+ try {
220
+ const pkg = JSON.parse(fs.readFileSync(candidate, 'utf-8')) as { version?: string };
221
+ if (typeof pkg.version === 'string' && pkg.version.length > 0) {
222
+ return pkg.version;
223
+ }
224
+ } catch {
225
+ // continue to next candidate
226
+ }
227
+ }
228
+ }
229
+ return 'unknown';
230
+ }
231
+
232
+ /**
233
+ * Enumerate all tables in a SQLite database and return per-table row counts.
234
+ *
235
+ * Opens the DB read-only. If the file is corrupt or the DB cannot be opened,
236
+ * returns an empty record (does not throw).
237
+ *
238
+ * @param dbPath - Absolute path to a SQLite database file.
239
+ * @returns Map of table name → row count.
240
+ */
241
+ function rowCountsForDb(dbPath: string): Record<string, number> {
242
+ const counts: Record<string, number> = {};
243
+ let db: DatabaseSync | null = null;
244
+ try {
245
+ db = new DatabaseSync(dbPath, { readOnly: true });
246
+ // Enumerate user tables
247
+ const rows = db
248
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
249
+ .all() as Array<{ name: string }>;
250
+ for (const row of rows) {
251
+ const result = db.prepare(`SELECT COUNT(*) AS cnt FROM "${row.name}"`).get() as
252
+ | { cnt: number }
253
+ | undefined;
254
+ counts[row.name] = result?.cnt ?? 0;
255
+ }
256
+ } catch {
257
+ // Non-throwing: return what we have
258
+ } finally {
259
+ try {
260
+ db?.close();
261
+ } catch {
262
+ // ignore
263
+ }
264
+ }
265
+ return counts;
266
+ }
267
+
268
+ /**
269
+ * Read the most recently applied Drizzle migration identifier from a DB.
270
+ *
271
+ * Looks for a `__drizzle_migrations` table (Drizzle v1 beta naming convention)
272
+ * or the older `drizzle_migrations` table, reads the latest `folder_millis`
273
+ * value (or `created_at` for older schemas). Returns "unknown" if not found.
274
+ *
275
+ * @param dbPath - Absolute path to a SQLite database file.
276
+ * @returns Migration identifier string or "unknown".
277
+ */
278
+ function schemaVersionForDb(dbPath: string): string {
279
+ let db: DatabaseSync | null = null;
280
+ try {
281
+ db = new DatabaseSync(dbPath, { readOnly: true });
282
+
283
+ // Check which migration table exists
284
+ const tables = db
285
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%drizzle%'")
286
+ .all() as Array<{ name: string }>;
287
+
288
+ if (tables.length === 0) return 'unknown';
289
+
290
+ const tableName = tables[0]!.name;
291
+
292
+ // Try folder_millis column first (Drizzle v1 convention)
293
+ try {
294
+ const row = db
295
+ .prepare(`SELECT folder_millis FROM "${tableName}" ORDER BY folder_millis DESC LIMIT 1`)
296
+ .get() as { folder_millis: number } | undefined;
297
+ if (row?.folder_millis != null) {
298
+ return String(row.folder_millis);
299
+ }
300
+ } catch {
301
+ // column not present
302
+ }
303
+
304
+ // Fallback: created_at column
305
+ try {
306
+ const row = db
307
+ .prepare(`SELECT created_at FROM "${tableName}" ORDER BY created_at DESC LIMIT 1`)
308
+ .get() as { created_at: string | number } | undefined;
309
+ if (row?.created_at != null) {
310
+ return String(row.created_at);
311
+ }
312
+ } catch {
313
+ // column not present
314
+ }
315
+
316
+ return 'unknown';
317
+ } catch {
318
+ return 'unknown';
319
+ } finally {
320
+ try {
321
+ db?.close();
322
+ } catch {
323
+ // ignore
324
+ }
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Snapshot a SQLite database using `VACUUM INTO '<dest>'`.
330
+ *
331
+ * Opens the source DB, runs a WAL checkpoint, then VACUUM INTO to produce a
332
+ * clean snapshot at destPath. Skips silently if the source does not exist.
333
+ *
334
+ * @param srcPath - Absolute path to the source DB file.
335
+ * @param destPath - Absolute path for the snapshot output.
336
+ * @returns True if snapshot was created; false if source did not exist.
337
+ */
338
+ function vacuumIntoStaging(srcPath: string, destPath: string): boolean {
339
+ if (!fs.existsSync(srcPath)) {
340
+ return false;
341
+ }
342
+ let db: DatabaseSync | null = null;
343
+ try {
344
+ db = new DatabaseSync(srcPath);
345
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
346
+ db.exec(`VACUUM INTO '${destPath.replace(/'/g, "''")}'`);
347
+ return true;
348
+ } finally {
349
+ try {
350
+ db?.close();
351
+ } catch {
352
+ // ignore
353
+ }
354
+ }
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Public API
359
+ // ---------------------------------------------------------------------------
360
+
361
+ /**
362
+ * Pack a CLEO backup bundle.
363
+ *
364
+ * Creates a `.cleobundle.tar.gz` (or `.enc.cleobundle.tar.gz`) containing
365
+ * VACUUM INTO snapshots of all in-scope databases, JSON config files,
366
+ * the global-salt (for global/all scopes), a manifest.json, a bundled JSON
367
+ * Schema, and a GNU-format checksums.sha256 file.
368
+ *
369
+ * The manifest.json is always written as the first tar entry to enable
370
+ * efficient streaming inspection without reading the full archive (ADR-038 §1).
371
+ *
372
+ * @param input - Pack options (scope, paths, encryption).
373
+ * @returns Result containing bundle path, size, manifest, and file count.
374
+ * @throws {Error} If encrypt=true but no passphrase is provided.
375
+ * @throws {T310MigrationRequiredError} If the project is on the pre-T310 topology.
376
+ *
377
+ * @task T347
378
+ * @epic T311
379
+ *
380
+ * @example
381
+ * ```typescript
382
+ * const result = await packBundle({
383
+ * scope: 'project',
384
+ * projectRoot: '/my/project',
385
+ * outputPath: '/tmp/my-project-20260408.cleobundle.tar.gz',
386
+ * });
387
+ * console.log(result.bundlePath, result.size);
388
+ * ```
389
+ */
390
+ export async function packBundle(input: PackBundleInput): Promise<PackBundleResult> {
391
+ // ----- 1. Validate input ------------------------------------------------
392
+ if (input.encrypt === true && !input.passphrase) {
393
+ throw new Error('packBundle: passphrase is required when encrypt=true');
394
+ }
395
+
396
+ const includesProject = input.scope === 'project' || input.scope === 'all';
397
+ const includesGlobal = input.scope === 'global' || input.scope === 'all';
398
+
399
+ if (includesProject && !input.projectRoot) {
400
+ throw new Error(`packBundle: projectRoot is required for scope "${input.scope}"`);
401
+ }
402
+
403
+ const resolvedProjectRoot = includesProject ? (input.projectRoot ?? getProjectRoot()) : '';
404
+
405
+ // ----- 2. T310 readiness check (project/all) ----------------------------
406
+ if (includesProject) {
407
+ assertT310Ready(resolvedProjectRoot);
408
+ }
409
+
410
+ // ----- 3. Create temp staging directory ---------------------------------
411
+ const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cleo-pack-'));
412
+
413
+ try {
414
+ // Subdirectories
415
+ fs.mkdirSync(path.join(stagingDir, 'databases'), { recursive: true });
416
+ fs.mkdirSync(path.join(stagingDir, 'json'), { recursive: true });
417
+ fs.mkdirSync(path.join(stagingDir, 'schemas'), { recursive: true });
418
+ if (includesGlobal) {
419
+ fs.mkdirSync(path.join(stagingDir, 'global'), { recursive: true });
420
+ }
421
+
422
+ // ----- 4a. Copy JSON Schema from @cleocode/contracts ----------------
423
+ const contractsSchemasDir = resolveContractsSchemasDir();
424
+ fs.copyFileSync(
425
+ path.join(contractsSchemasDir, 'manifest-v1.json'),
426
+ path.join(stagingDir, 'schemas', 'manifest-v1.json'),
427
+ );
428
+
429
+ // ----- 4b. Stage databases ------------------------------------------
430
+ const stagedDbs: Array<{
431
+ name: 'tasks' | 'brain' | 'conduit' | 'nexus' | 'signaldock';
432
+ srcPath: string;
433
+ stagedPath: string;
434
+ }> = [];
435
+
436
+ if (includesProject) {
437
+ const cleoDir = path.join(resolvedProjectRoot, '.cleo');
438
+ for (const name of ['tasks', 'brain'] as const) {
439
+ const srcPath = path.join(cleoDir, `${name}.db`);
440
+ const stagedPath = path.join(stagingDir, 'databases', `${name}.db`);
441
+ const snapped = vacuumIntoStaging(srcPath, stagedPath);
442
+ if (snapped) {
443
+ stagedDbs.push({ name, srcPath, stagedPath });
444
+ } else {
445
+ process.stderr.write(
446
+ `[backup-pack] WARNING: ${name}.db not found at ${srcPath}, skipping.\n`,
447
+ );
448
+ }
449
+ }
450
+ // conduit.db
451
+ const conduitSrc = getConduitDbPath(resolvedProjectRoot);
452
+ const conduitDest = path.join(stagingDir, 'databases', 'conduit.db');
453
+ const conduitSnapped = vacuumIntoStaging(conduitSrc, conduitDest);
454
+ if (conduitSnapped) {
455
+ stagedDbs.push({ name: 'conduit', srcPath: conduitSrc, stagedPath: conduitDest });
456
+ } else {
457
+ process.stderr.write(
458
+ `[backup-pack] WARNING: conduit.db not found at ${conduitSrc}, skipping.\n`,
459
+ );
460
+ }
461
+ }
462
+
463
+ if (includesGlobal) {
464
+ // nexus.db
465
+ const nexusSrc = getNexusDbPath();
466
+ const nexusDest = path.join(stagingDir, 'databases', 'nexus.db');
467
+ const nexusSnapped = vacuumIntoStaging(nexusSrc, nexusDest);
468
+ if (nexusSnapped) {
469
+ stagedDbs.push({ name: 'nexus', srcPath: nexusSrc, stagedPath: nexusDest });
470
+ } else {
471
+ process.stderr.write(
472
+ `[backup-pack] WARNING: nexus.db not found at ${nexusSrc}, skipping.\n`,
473
+ );
474
+ }
475
+
476
+ // signaldock.db
477
+ const sdSrc = getGlobalSignaldockDbPath();
478
+ const sdDest = path.join(stagingDir, 'databases', 'signaldock.db');
479
+ const sdSnapped = vacuumIntoStaging(sdSrc, sdDest);
480
+ if (sdSnapped) {
481
+ stagedDbs.push({ name: 'signaldock', srcPath: sdSrc, stagedPath: sdDest });
482
+ } else {
483
+ process.stderr.write(
484
+ `[backup-pack] WARNING: signaldock.db not found at ${sdSrc}, skipping.\n`,
485
+ );
486
+ }
487
+ }
488
+
489
+ // ----- 4c. Stage JSON files ------------------------------------------
490
+ const stagedJson: Array<{
491
+ filename: 'json/config.json' | 'json/project-info.json' | 'json/project-context.json';
492
+ stagedPath: string;
493
+ }> = [];
494
+
495
+ if (includesProject) {
496
+ const cleoDir = path.join(resolvedProjectRoot, '.cleo');
497
+ const jsonFiles = [
498
+ { name: 'config.json', filename: 'json/config.json' as const },
499
+ { name: 'project-info.json', filename: 'json/project-info.json' as const },
500
+ { name: 'project-context.json', filename: 'json/project-context.json' as const },
501
+ ];
502
+ for (const jf of jsonFiles) {
503
+ const srcPath = path.join(cleoDir, jf.name);
504
+ if (!fs.existsSync(srcPath)) {
505
+ process.stderr.write(
506
+ `[backup-pack] WARNING: ${jf.name} not found at ${srcPath}, skipping.\n`,
507
+ );
508
+ continue;
509
+ }
510
+ const destPath = path.join(stagingDir, 'json', jf.name);
511
+ fs.copyFileSync(srcPath, destPath);
512
+ stagedJson.push({ filename: jf.filename, stagedPath: destPath });
513
+ }
514
+ }
515
+
516
+ // ----- 4d. Stage global-salt -----------------------------------------
517
+ let globalSaltStaged = false;
518
+ if (includesGlobal) {
519
+ const saltSrc = getGlobalSaltPath();
520
+ if (fs.existsSync(saltSrc)) {
521
+ process.stderr.write(
522
+ '[backup-pack] WARNING: global-salt is included in this bundle. ' +
523
+ 'Importing this bundle will overwrite the global-salt on the target machine, ' +
524
+ 'invalidating all agent API keys. Agents will require re-authentication.\n',
525
+ );
526
+ fs.copyFileSync(saltSrc, path.join(stagingDir, 'global', 'global-salt'));
527
+ globalSaltStaged = true;
528
+ } else {
529
+ process.stderr.write(
530
+ `[backup-pack] WARNING: global-salt not found at ${saltSrc}, skipping.\n`,
531
+ );
532
+ }
533
+ }
534
+
535
+ // ----- 5. Compute SHA-256 checksums for all staged files (excl manifest.json) ---
536
+ // Collect all relative paths under staging (excl manifest.json itself)
537
+ const checksumLines: string[] = [];
538
+
539
+ // schemas/manifest-v1.json
540
+ const schemaRelPath = 'schemas/manifest-v1.json';
541
+ const schemaHash = sha256OfFile(path.join(stagingDir, 'schemas', 'manifest-v1.json'));
542
+ checksumLines.push(`${schemaHash} ${schemaRelPath}`);
543
+
544
+ // databases
545
+ for (const db of stagedDbs) {
546
+ const relPath = `databases/${db.name}.db`;
547
+ const hash = sha256OfFile(db.stagedPath);
548
+ checksumLines.push(`${hash} ${relPath}`);
549
+ }
550
+
551
+ // json files
552
+ for (const jf of stagedJson) {
553
+ const hash = sha256OfFile(jf.stagedPath);
554
+ checksumLines.push(`${hash} ${jf.filename}`);
555
+ }
556
+
557
+ // global-salt
558
+ if (globalSaltStaged) {
559
+ const saltHash = sha256OfFile(path.join(stagingDir, 'global', 'global-salt'));
560
+ checksumLines.push(`${saltHash} global/global-salt`);
561
+ }
562
+
563
+ // Write checksums.sha256
564
+ const checksumContent = checksumLines.join('\n') + '\n';
565
+ fs.writeFileSync(path.join(stagingDir, 'checksums.sha256'), checksumContent, 'utf-8');
566
+
567
+ // ----- 6. Compute project fingerprint -----------------------------------
568
+ const cleoVersion = readLocalCleoVersion();
569
+ let projectFingerprint: string | undefined;
570
+ if (includesProject) {
571
+ const piPath = path.join(resolvedProjectRoot, '.cleo', 'project-info.json');
572
+ if (fs.existsSync(piPath)) {
573
+ projectFingerprint = sha256OfFile(piPath);
574
+ }
575
+ }
576
+
577
+ // ----- 7. Build manifest databases entries ------------------------------
578
+ const databaseEntries: BackupManifest['databases'] = stagedDbs.map((db) => {
579
+ const stat = fs.statSync(db.stagedPath);
580
+ return {
581
+ name: db.name,
582
+ filename: `databases/${db.name}.db`,
583
+ size: stat.size,
584
+ sha256: sha256OfFile(db.stagedPath),
585
+ schemaVersion: schemaVersionForDb(db.stagedPath),
586
+ rowCounts: rowCountsForDb(db.stagedPath),
587
+ };
588
+ });
589
+
590
+ // ----- 8. Build manifest json entries -----------------------------------
591
+ const jsonEntries: BackupManifest['json'] = stagedJson.map((jf) => {
592
+ const stat = fs.statSync(jf.stagedPath);
593
+ return {
594
+ filename: jf.filename,
595
+ size: stat.size,
596
+ sha256: sha256OfFile(jf.stagedPath),
597
+ };
598
+ });
599
+
600
+ // ----- 9. Build manifest globalFiles entries ----------------------------
601
+ let globalFiles: BackupManifest['globalFiles'];
602
+ if (includesGlobal && globalSaltStaged) {
603
+ const saltStaged = path.join(stagingDir, 'global', 'global-salt');
604
+ const stat = fs.statSync(saltStaged);
605
+ globalFiles = [
606
+ {
607
+ filename: 'global/global-salt',
608
+ size: stat.size,
609
+ sha256: sha256OfFile(saltStaged),
610
+ },
611
+ ];
612
+ }
613
+
614
+ // ----- 10. Compute manifestHash (with placeholder empty string) ---------
615
+ // Per spec §4.1 and §5.1 step 11: compute SHA-256 of manifest JSON
616
+ // with integrity.manifestHash set to "". Then set it.
617
+ const manifestWithPlaceholder: BackupManifest = {
618
+ $schema: './schemas/manifest-v1.json',
619
+ manifestVersion: '1.0.0',
620
+ backup: {
621
+ createdAt: new Date().toISOString(),
622
+ createdBy: `cleo v${cleoVersion}`,
623
+ scope: input.scope,
624
+ ...(input.projectName != null ? { projectName: input.projectName } : {}),
625
+ ...(projectFingerprint != null ? { projectFingerprint } : {}),
626
+ machineFingerprint: sha256OfMachineKey(),
627
+ cleoVersion,
628
+ encrypted: input.encrypt === true,
629
+ },
630
+ databases: databaseEntries,
631
+ json: jsonEntries,
632
+ ...(globalFiles != null ? { globalFiles } : {}),
633
+ integrity: {
634
+ algorithm: 'sha256',
635
+ checksumsFile: 'checksums.sha256',
636
+ manifestHash: '',
637
+ },
638
+ };
639
+
640
+ const manifestJsonForHash = JSON.stringify(manifestWithPlaceholder);
641
+ const manifestHash = sha256OfBuffer(Buffer.from(manifestJsonForHash, 'utf-8'));
642
+
643
+ // Final manifest with real hash
644
+ const manifest: BackupManifest = {
645
+ ...manifestWithPlaceholder,
646
+ integrity: {
647
+ algorithm: 'sha256',
648
+ checksumsFile: 'checksums.sha256',
649
+ manifestHash,
650
+ },
651
+ };
652
+
653
+ // Write manifest.json to staging
654
+ const manifestContent = JSON.stringify(manifest, null, 2);
655
+ fs.writeFileSync(path.join(stagingDir, 'manifest.json'), manifestContent, 'utf-8');
656
+
657
+ // ----- 11. Create tarball with manifest.json as FIRST entry -----------
658
+ // Spec §2 rule 1: manifest.json MUST be written as the first tar entry.
659
+ // We achieve this by listing manifest.json first in the file list.
660
+
661
+ // Collect all relative paths to include, in the required order
662
+ const tarFiles: string[] = [];
663
+
664
+ // 1st: manifest.json
665
+ tarFiles.push('manifest.json');
666
+
667
+ // 2nd: schemas/
668
+ tarFiles.push('schemas/manifest-v1.json');
669
+
670
+ // 3rd: databases/
671
+ for (const db of stagedDbs) {
672
+ tarFiles.push(`databases/${db.name}.db`);
673
+ }
674
+
675
+ // 4th: json/
676
+ for (const jf of stagedJson) {
677
+ tarFiles.push(jf.filename);
678
+ }
679
+
680
+ // 5th: global/
681
+ if (globalSaltStaged) {
682
+ tarFiles.push('global/global-salt');
683
+ }
684
+
685
+ // 6th: checksums.sha256
686
+ tarFiles.push('checksums.sha256');
687
+
688
+ const tmpTarPath = `${stagingDir}.tar.gz`;
689
+ await tarCreate(
690
+ {
691
+ gzip: true,
692
+ file: tmpTarPath,
693
+ cwd: stagingDir,
694
+ },
695
+ tarFiles,
696
+ );
697
+
698
+ // ----- 12. Optionally encrypt -----------------------------------------
699
+ if (input.encrypt === true && input.passphrase) {
700
+ const tarBuffer = fs.readFileSync(tmpTarPath);
701
+ const encrypted = encryptBundle(tarBuffer, input.passphrase);
702
+ fs.writeFileSync(input.outputPath, encrypted);
703
+ try {
704
+ fs.unlinkSync(tmpTarPath);
705
+ } catch {
706
+ // best-effort cleanup
707
+ }
708
+ } else {
709
+ fs.renameSync(tmpTarPath, input.outputPath);
710
+ }
711
+
712
+ // ----- 13. Compute final bundle size and file count -------------------
713
+ const bundleStat = fs.statSync(input.outputPath);
714
+ const fileCount = stagedDbs.length + stagedJson.length + (globalSaltStaged ? 1 : 0);
715
+
716
+ return {
717
+ bundlePath: input.outputPath,
718
+ size: bundleStat.size,
719
+ manifest,
720
+ fileCount,
721
+ };
722
+ } finally {
723
+ // ----- Cleanup staging dir (always, even on error) -------------------
724
+ try {
725
+ fs.rmSync(stagingDir, { recursive: true, force: true });
726
+ } catch {
727
+ // best-effort
728
+ }
729
+ // Also clean up the tmp tar file if still present (e.g. error before rename)
730
+ try {
731
+ const tmpTarPath = `${stagingDir}.tar.gz`;
732
+ if (fs.existsSync(tmpTarPath)) {
733
+ fs.unlinkSync(tmpTarPath);
734
+ }
735
+ } catch {
736
+ // best-effort
737
+ }
738
+ }
739
+ }