@cleocode/core 2026.4.11 → 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/dist/codebase-map/analyzers/architecture.d.ts.map +1 -1
- package/dist/codebase-map/analyzers/architecture.js +0 -1
- package/dist/codebase-map/analyzers/architecture.js.map +1 -1
- package/dist/conduit/local-transport.d.ts +18 -8
- package/dist/conduit/local-transport.d.ts.map +1 -1
- package/dist/conduit/local-transport.js +23 -13
- package/dist/conduit/local-transport.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -1
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +6 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.js +175 -68950
- package/dist/index.js.map +1 -7
- package/dist/init.d.ts +1 -2
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +1 -2
- package/dist/init.js.map +1 -1
- package/dist/internal.d.ts +8 -3
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +13 -6
- package/dist/internal.js.map +1 -1
- package/dist/memory/learnings.d.ts +2 -2
- package/dist/memory/patterns.d.ts +6 -6
- package/dist/output.d.ts +32 -11
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +67 -67
- package/dist/output.js.map +1 -1
- package/dist/paths.js +80 -14
- package/dist/paths.js.map +1 -1
- package/dist/skills/dynamic-skill-generator.d.ts +0 -2
- package/dist/skills/dynamic-skill-generator.d.ts.map +1 -1
- package/dist/skills/dynamic-skill-generator.js.map +1 -1
- package/dist/store/agent-registry-accessor.d.ts +203 -12
- package/dist/store/agent-registry-accessor.d.ts.map +1 -1
- package/dist/store/agent-registry-accessor.js +618 -100
- package/dist/store/agent-registry-accessor.js.map +1 -1
- package/dist/store/api-key-kdf.d.ts +73 -0
- package/dist/store/api-key-kdf.d.ts.map +1 -0
- package/dist/store/api-key-kdf.js +84 -0
- package/dist/store/api-key-kdf.js.map +1 -0
- package/dist/store/cleanup-legacy.js +171 -0
- package/dist/store/cleanup-legacy.js.map +1 -0
- package/dist/store/conduit-sqlite.d.ts +184 -0
- package/dist/store/conduit-sqlite.d.ts.map +1 -0
- package/dist/store/conduit-sqlite.js +570 -0
- package/dist/store/conduit-sqlite.js.map +1 -0
- package/dist/store/global-salt.d.ts +78 -0
- package/dist/store/global-salt.d.ts.map +1 -0
- package/dist/store/global-salt.js +147 -0
- package/dist/store/global-salt.js.map +1 -0
- package/dist/store/migrate-signaldock-to-conduit.d.ts +81 -0
- package/dist/store/migrate-signaldock-to-conduit.d.ts.map +1 -0
- package/dist/store/migrate-signaldock-to-conduit.js +555 -0
- package/dist/store/migrate-signaldock-to-conduit.js.map +1 -0
- package/dist/store/nexus-sqlite.js +28 -3
- package/dist/store/nexus-sqlite.js.map +1 -1
- package/dist/store/signaldock-sqlite.d.ts +122 -19
- package/dist/store/signaldock-sqlite.d.ts.map +1 -1
- package/dist/store/signaldock-sqlite.js +401 -251
- package/dist/store/signaldock-sqlite.js.map +1 -1
- package/dist/store/sqlite-backup.js +122 -4
- package/dist/store/sqlite-backup.js.map +1 -1
- package/dist/system/backup.d.ts +0 -26
- package/dist/system/backup.d.ts.map +1 -1
- package/dist/system/runtime.d.ts +0 -2
- package/dist/system/runtime.d.ts.map +1 -1
- package/dist/system/runtime.js +3 -3
- package/dist/system/runtime.js.map +1 -1
- package/dist/tasks/add.d.ts +1 -1
- package/dist/tasks/add.d.ts.map +1 -1
- package/dist/tasks/add.js +98 -23
- package/dist/tasks/add.js.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +4 -1
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/find.d.ts.map +1 -1
- package/dist/tasks/find.js +4 -1
- package/dist/tasks/find.js.map +1 -1
- package/dist/tasks/labels.d.ts.map +1 -1
- package/dist/tasks/labels.js +4 -1
- package/dist/tasks/labels.js.map +1 -1
- package/dist/tasks/relates.d.ts.map +1 -1
- package/dist/tasks/relates.js +16 -4
- package/dist/tasks/relates.js.map +1 -1
- package/dist/tasks/show.d.ts.map +1 -1
- package/dist/tasks/show.js +4 -1
- package/dist/tasks/show.js.map +1 -1
- package/dist/tasks/update.d.ts.map +1 -1
- package/dist/tasks/update.js +32 -6
- package/dist/tasks/update.js.map +1 -1
- package/dist/validation/engine.d.ts.map +1 -1
- package/dist/validation/engine.js +16 -4
- package/dist/validation/engine.js.map +1 -1
- package/dist/validation/param-utils.d.ts +5 -3
- package/dist/validation/param-utils.d.ts.map +1 -1
- package/dist/validation/param-utils.js +8 -6
- package/dist/validation/param-utils.js.map +1 -1
- package/dist/validation/protocols/_shared.d.ts.map +1 -1
- package/dist/validation/protocols/_shared.js +13 -6
- package/dist/validation/protocols/_shared.js.map +1 -1
- package/package.json +9 -7
- package/src/adapters/__tests__/manager.test.ts +0 -1
- package/src/codebase-map/analyzers/architecture.ts +0 -1
- package/src/conduit/__tests__/local-credential-flow.test.ts +20 -18
- package/src/conduit/__tests__/local-transport.test.ts +14 -12
- package/src/conduit/local-transport.ts +23 -13
- package/src/config.ts +0 -1
- package/src/errors.ts +24 -0
- package/src/hooks/handlers/__tests__/hook-automation-e2e.test.ts +2 -5
- package/src/init.ts +1 -2
- package/src/internal.ts +96 -2
- package/src/lifecycle/cant/lifecycle-rcasd.cant +133 -0
- package/src/memory/__tests__/engine-compat.test.ts +2 -2
- package/src/memory/__tests__/pipeline-manifest-sqlite.test.ts +4 -4
- package/src/observability/__tests__/index.test.ts +4 -4
- package/src/observability/__tests__/log-filter.test.ts +4 -4
- package/src/output.ts +73 -75
- package/src/sessions/__tests__/session-grade.integration.test.ts +1 -1
- package/src/sessions/__tests__/session-grade.test.ts +2 -2
- package/src/skills/__tests__/dynamic-skill-generator.test.ts +0 -2
- package/src/skills/dynamic-skill-generator.ts +0 -2
- package/src/store/__tests__/agent-registry-accessor.test.ts +807 -0
- package/src/store/__tests__/api-key-kdf.test.ts +113 -0
- 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__/conduit-sqlite.test.ts +413 -0
- package/src/store/__tests__/global-salt.test.ts +195 -0
- package/src/store/__tests__/migrate-signaldock-to-conduit.test.ts +715 -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__/signaldock-sqlite.test.ts +652 -0
- package/src/store/__tests__/sqlite-backup-global.test.ts +307 -3
- package/src/store/__tests__/sqlite-backup.test.ts +5 -1
- package/src/store/__tests__/t310-integration.test.ts +1150 -0
- package/src/store/__tests__/t310-readiness.test.ts +111 -0
- package/src/store/__tests__/t311-integration.test.ts +661 -0
- package/src/store/agent-registry-accessor.ts +847 -140
- package/src/store/api-key-kdf.ts +104 -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/conduit-sqlite.ts +655 -0
- package/src/store/global-salt.ts +175 -0
- package/src/store/migrate-signaldock-to-conduit.ts +669 -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/signaldock-sqlite.ts +431 -254
- package/src/store/sqlite-backup.ts +185 -10
- package/src/store/t310-readiness.ts +119 -0
- package/src/system/backup.ts +2 -62
- package/src/system/runtime.ts +4 -6
- package/src/tasks/__tests__/error-hints.test.ts +256 -0
- package/src/tasks/add.ts +99 -9
- package/src/tasks/complete.ts +4 -1
- package/src/tasks/find.ts +4 -1
- package/src/tasks/labels.ts +4 -1
- package/src/tasks/relates.ts +16 -4
- package/src/tasks/show.ts +4 -1
- package/src/tasks/update.ts +32 -3
- package/src/validation/__tests__/error-hints.test.ts +97 -0
- package/src/validation/engine.ts +16 -1
- package/src/validation/param-utils.ts +10 -7
- package/src/validation/protocols/_shared.ts +14 -6
- package/src/validation/protocols/cant/architecture-decision.cant +80 -0
- package/src/validation/protocols/cant/artifact-publish.cant +95 -0
- package/src/validation/protocols/cant/consensus.cant +74 -0
- package/src/validation/protocols/cant/contribution.cant +82 -0
- package/src/validation/protocols/cant/decomposition.cant +92 -0
- package/src/validation/protocols/cant/implementation.cant +67 -0
- package/src/validation/protocols/cant/provenance.cant +88 -0
- package/src/validation/protocols/cant/release.cant +96 -0
- package/src/validation/protocols/cant/research.cant +66 -0
- package/src/validation/protocols/cant/specification.cant +67 -0
- package/src/validation/protocols/cant/testing.cant +88 -0
- package/src/validation/protocols/cant/validation.cant +65 -0
- package/src/validation/protocols/protocols-markdown/decomposition.md +0 -4
- package/templates/config.template.json +0 -1
- package/templates/global-config.template.json +0 -1
|
@@ -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
|
+
}
|