@celilo/cli 0.1.0

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.
Files changed (267) hide show
  1. package/README.md +1566 -0
  2. package/bin/celilo +16 -0
  3. package/drizzle/0000_complex_puma.sql +179 -0
  4. package/drizzle/0001_dizzy_wolfpack.sql +2 -0
  5. package/drizzle/0002_web_routes.sql +16 -0
  6. package/drizzle/0003_backup_storage.sql +32 -0
  7. package/drizzle/meta/0000_snapshot.json +1151 -0
  8. package/drizzle/meta/0001_snapshot.json +1167 -0
  9. package/drizzle/meta/0002_snapshot.json +1257 -0
  10. package/drizzle/meta/_journal.json +27 -0
  11. package/package.json +64 -0
  12. package/schemas/system_config.json +106 -0
  13. package/src/__integration__/container-services-cli.integration.test.ts +246 -0
  14. package/src/ansible/dependencies.test.ts +309 -0
  15. package/src/ansible/dependencies.ts +896 -0
  16. package/src/ansible/inventory.test.ts +463 -0
  17. package/src/ansible/inventory.ts +445 -0
  18. package/src/ansible/secrets.ts +222 -0
  19. package/src/ansible/validation.test.ts +92 -0
  20. package/src/ansible/validation.ts +272 -0
  21. package/src/api-clients/digitalocean.ts +94 -0
  22. package/src/api-clients/proxmox.ts +655 -0
  23. package/src/capabilities/logging-wrapper.test.ts +217 -0
  24. package/src/capabilities/lookup.test.ts +149 -0
  25. package/src/capabilities/lookup.ts +89 -0
  26. package/src/capabilities/public-web-helpers.test.ts +198 -0
  27. package/src/capabilities/public-web-publish.test.ts +458 -0
  28. package/src/capabilities/registration.test.ts +395 -0
  29. package/src/capabilities/registration.ts +200 -0
  30. package/src/capabilities/route-validation.test.ts +121 -0
  31. package/src/capabilities/route-validation.ts +96 -0
  32. package/src/capabilities/secret-ref.test.ts +313 -0
  33. package/src/capabilities/secret-validation.ts +157 -0
  34. package/src/capabilities/secrets.test.ts +750 -0
  35. package/src/capabilities/secrets.ts +244 -0
  36. package/src/capabilities/validation.test.ts +613 -0
  37. package/src/capabilities/validation.ts +160 -0
  38. package/src/capabilities/well-known.test.ts +238 -0
  39. package/src/capabilities/well-known.ts +222 -0
  40. package/src/cli/cli.test.ts +654 -0
  41. package/src/cli/command-registry.ts +742 -0
  42. package/src/cli/command-tree-parser.test.ts +180 -0
  43. package/src/cli/command-tree-parser.ts +193 -0
  44. package/src/cli/commands/backup-create.ts +137 -0
  45. package/src/cli/commands/backup-delete.ts +74 -0
  46. package/src/cli/commands/backup-import.ts +97 -0
  47. package/src/cli/commands/backup-list.ts +132 -0
  48. package/src/cli/commands/backup-name.ts +73 -0
  49. package/src/cli/commands/backup-prune.ts +98 -0
  50. package/src/cli/commands/backup-restore.ts +122 -0
  51. package/src/cli/commands/capability-info.ts +121 -0
  52. package/src/cli/commands/capability-list.ts +47 -0
  53. package/src/cli/commands/completion.ts +87 -0
  54. package/src/cli/commands/hook-run.ts +176 -0
  55. package/src/cli/commands/ipam.ts +607 -0
  56. package/src/cli/commands/machine-add.ts +235 -0
  57. package/src/cli/commands/machine-earmark.ts +82 -0
  58. package/src/cli/commands/machine-list.ts +77 -0
  59. package/src/cli/commands/machine-remove.ts +90 -0
  60. package/src/cli/commands/machine-status.ts +131 -0
  61. package/src/cli/commands/module-audit.ts +51 -0
  62. package/src/cli/commands/module-build.ts +60 -0
  63. package/src/cli/commands/module-config.ts +170 -0
  64. package/src/cli/commands/module-deploy.ts +71 -0
  65. package/src/cli/commands/module-generate.ts +236 -0
  66. package/src/cli/commands/module-health.ts +108 -0
  67. package/src/cli/commands/module-import.ts +80 -0
  68. package/src/cli/commands/module-list.ts +43 -0
  69. package/src/cli/commands/module-logs.ts +73 -0
  70. package/src/cli/commands/module-remove.ts +162 -0
  71. package/src/cli/commands/module-show.ts +208 -0
  72. package/src/cli/commands/module-status.ts +131 -0
  73. package/src/cli/commands/module-types.ts +189 -0
  74. package/src/cli/commands/module-upgrade.ts +192 -0
  75. package/src/cli/commands/package.ts +68 -0
  76. package/src/cli/commands/secret-list.ts +99 -0
  77. package/src/cli/commands/secret-set.ts +134 -0
  78. package/src/cli/commands/service-add-digitalocean.ts +133 -0
  79. package/src/cli/commands/service-add-proxmox.ts +342 -0
  80. package/src/cli/commands/service-config-get.ts +83 -0
  81. package/src/cli/commands/service-config-set.ts +145 -0
  82. package/src/cli/commands/service-list.ts +74 -0
  83. package/src/cli/commands/service-reconfigure.ts +230 -0
  84. package/src/cli/commands/service-remove.ts +103 -0
  85. package/src/cli/commands/service-verify.ts +240 -0
  86. package/src/cli/commands/status.ts +216 -0
  87. package/src/cli/commands/storage-add-local.ts +106 -0
  88. package/src/cli/commands/storage-add-s3.ts +114 -0
  89. package/src/cli/commands/storage-list.ts +72 -0
  90. package/src/cli/commands/storage-remove.ts +54 -0
  91. package/src/cli/commands/storage-set-default.ts +44 -0
  92. package/src/cli/commands/storage-verify.ts +54 -0
  93. package/src/cli/commands/system-config.ts +168 -0
  94. package/src/cli/commands/system-init.ts +314 -0
  95. package/src/cli/commands/system-secret-get.ts +98 -0
  96. package/src/cli/commands/system-secret-set.ts +76 -0
  97. package/src/cli/commands/system-vault-password.ts +34 -0
  98. package/src/cli/completion.test.ts +37 -0
  99. package/src/cli/completion.ts +482 -0
  100. package/src/cli/fuel-gauge.test.ts +208 -0
  101. package/src/cli/fuel-gauge.ts +405 -0
  102. package/src/cli/generate-zsh-completion.test.ts +95 -0
  103. package/src/cli/generate-zsh-completion.ts +497 -0
  104. package/src/cli/index.ts +1583 -0
  105. package/src/cli/interactive-config.test.ts +201 -0
  106. package/src/cli/interactive-config.ts +62 -0
  107. package/src/cli/parser.test.ts +227 -0
  108. package/src/cli/parser.ts +244 -0
  109. package/src/cli/prompts.test.ts +33 -0
  110. package/src/cli/prompts.ts +121 -0
  111. package/src/cli/types.ts +38 -0
  112. package/src/cli/validators.test.ts +235 -0
  113. package/src/cli/validators.ts +188 -0
  114. package/src/config/env.ts +41 -0
  115. package/src/config/paths.test.ts +172 -0
  116. package/src/config/paths.ts +108 -0
  117. package/src/db/client.ts +190 -0
  118. package/src/db/migrate.ts +30 -0
  119. package/src/db/schema.test.ts +221 -0
  120. package/src/db/schema.ts +434 -0
  121. package/src/hooks/capability-loader-firewall.test.ts +246 -0
  122. package/src/hooks/capability-loader.test.ts +100 -0
  123. package/src/hooks/capability-loader.ts +520 -0
  124. package/src/hooks/define-hook.test.ts +488 -0
  125. package/src/hooks/executor.test.ts +462 -0
  126. package/src/hooks/executor.ts +469 -0
  127. package/src/hooks/logger.test.ts +54 -0
  128. package/src/hooks/logger.ts +95 -0
  129. package/src/hooks/test-fixtures/failing-hook.ts +13 -0
  130. package/src/hooks/test-fixtures/no-default-hook.ts +6 -0
  131. package/src/hooks/test-fixtures/success-hook.ts +20 -0
  132. package/src/hooks/test-fixtures/unbranded-hook.ts +11 -0
  133. package/src/hooks/test-fixtures/void-hook.ts +13 -0
  134. package/src/hooks/types.ts +89 -0
  135. package/src/infrastructure/property-extractor.test.ts +194 -0
  136. package/src/infrastructure/property-extractor.ts +151 -0
  137. package/src/ipam/allocator.test.ts +442 -0
  138. package/src/ipam/allocator.ts +369 -0
  139. package/src/ipam/auto-allocator.test.ts +247 -0
  140. package/src/ipam/auto-allocator.ts +270 -0
  141. package/src/ipam/subnet-parser.test.ts +107 -0
  142. package/src/ipam/subnet-parser.ts +136 -0
  143. package/src/manifest/contracts/index.ts +61 -0
  144. package/src/manifest/contracts/v1.ts +118 -0
  145. package/src/manifest/json-schema-roundtrip.test.ts +99 -0
  146. package/src/manifest/schema.ts +367 -0
  147. package/src/manifest/template-validator.test.ts +231 -0
  148. package/src/manifest/template-validator.ts +322 -0
  149. package/src/manifest/validate.test.ts +1180 -0
  150. package/src/manifest/validate.ts +415 -0
  151. package/src/module/import.test.ts +355 -0
  152. package/src/module/import.ts +676 -0
  153. package/src/module/packaging/audit.ts +169 -0
  154. package/src/module/packaging/build.ts +228 -0
  155. package/src/module/packaging/checksum.ts +41 -0
  156. package/src/module/packaging/extract.ts +234 -0
  157. package/src/module/packaging/signature.ts +47 -0
  158. package/src/secrets/encryption.test.ts +284 -0
  159. package/src/secrets/encryption.ts +162 -0
  160. package/src/secrets/generators.test.ts +112 -0
  161. package/src/secrets/generators.ts +127 -0
  162. package/src/secrets/master-key.test.ts +159 -0
  163. package/src/secrets/master-key.ts +114 -0
  164. package/src/secrets/storage.test.ts +115 -0
  165. package/src/secrets/storage.ts +106 -0
  166. package/src/secrets/vault.test.ts +35 -0
  167. package/src/secrets/vault.ts +42 -0
  168. package/src/services/backup-create.ts +532 -0
  169. package/src/services/backup-metadata.ts +198 -0
  170. package/src/services/backup-restore.ts +229 -0
  171. package/src/services/backup-retention.ts +84 -0
  172. package/src/services/backup-storage.ts +281 -0
  173. package/src/services/build-stream.test.ts +122 -0
  174. package/src/services/build-stream.ts +201 -0
  175. package/src/services/config-interview.ts +694 -0
  176. package/src/services/container-service.test.ts +298 -0
  177. package/src/services/container-service.ts +401 -0
  178. package/src/services/cross-module-data-manager.test.ts +405 -0
  179. package/src/services/cross-module-data-manager.ts +412 -0
  180. package/src/services/deploy-ansible.ts +88 -0
  181. package/src/services/deploy-planner.ts +153 -0
  182. package/src/services/deploy-preflight.ts +274 -0
  183. package/src/services/deploy-ssh.ts +131 -0
  184. package/src/services/deploy-terraform.test.ts +55 -0
  185. package/src/services/deploy-terraform.ts +445 -0
  186. package/src/services/deploy-validation.ts +311 -0
  187. package/src/services/dns-auto-register.ts +211 -0
  188. package/src/services/health-runner.ts +184 -0
  189. package/src/services/infrastructure-selector.test.ts +485 -0
  190. package/src/services/infrastructure-selector.ts +245 -0
  191. package/src/services/infrastructure-variable-resolver.test.ts +751 -0
  192. package/src/services/infrastructure-variable-resolver.ts +234 -0
  193. package/src/services/machine-detector.ts +328 -0
  194. package/src/services/machine-pool.test.ts +405 -0
  195. package/src/services/machine-pool.ts +316 -0
  196. package/src/services/manifest-validation.ts +120 -0
  197. package/src/services/module-build.test.ts +290 -0
  198. package/src/services/module-build.ts +431 -0
  199. package/src/services/module-config.test.ts +237 -0
  200. package/src/services/module-config.ts +298 -0
  201. package/src/services/module-deploy.ts +862 -0
  202. package/src/services/module-types-drift.test.ts +73 -0
  203. package/src/services/module-types-generator.test.ts +288 -0
  204. package/src/services/module-types-generator.ts +189 -0
  205. package/src/services/proxmox-state-recovery.ts +140 -0
  206. package/src/services/schema-validation.ts +155 -0
  207. package/src/services/secret-schema-loader.test.ts +311 -0
  208. package/src/services/secret-schema-loader.ts +239 -0
  209. package/src/services/ssh-key-manager.test.ts +283 -0
  210. package/src/services/ssh-key-manager.ts +193 -0
  211. package/src/services/storage-providers/local.ts +105 -0
  212. package/src/services/storage-providers/s3.ts +182 -0
  213. package/src/services/storage-providers/types.ts +24 -0
  214. package/src/services/system-config-schema-types.ts +25 -0
  215. package/src/services/system-config-validator.test.ts +160 -0
  216. package/src/services/system-config-validator.ts +74 -0
  217. package/src/services/system-init.test.ts +153 -0
  218. package/src/services/system-init.ts +253 -0
  219. package/src/services/terraform-safety.ts +174 -0
  220. package/src/services/zone-detector.test.ts +110 -0
  221. package/src/services/zone-detector.ts +102 -0
  222. package/src/services/zone-policy.test.ts +97 -0
  223. package/src/services/zone-policy.ts +126 -0
  224. package/src/templates/generator.test.ts +645 -0
  225. package/src/templates/generator.ts +1119 -0
  226. package/src/templates/types.ts +62 -0
  227. package/src/test-utils/INTERACTIVE_PROMPTS.md +167 -0
  228. package/src/test-utils/cli-context-interactive.test.ts +152 -0
  229. package/src/test-utils/cli-context-server.test.ts +66 -0
  230. package/src/test-utils/cli-context.test.ts +273 -0
  231. package/src/test-utils/cli-context.ts +677 -0
  232. package/src/test-utils/cli-result.test.ts +282 -0
  233. package/src/test-utils/cli-result.ts +241 -0
  234. package/src/test-utils/cli.ts +55 -0
  235. package/src/test-utils/completion-harness.test.ts +126 -0
  236. package/src/test-utils/completion-harness.ts +82 -0
  237. package/src/test-utils/database.test.ts +182 -0
  238. package/src/test-utils/database.ts +126 -0
  239. package/src/test-utils/filesystem.test.ts +208 -0
  240. package/src/test-utils/filesystem.ts +142 -0
  241. package/src/test-utils/fixtures.test.ts +123 -0
  242. package/src/test-utils/fixtures.ts +160 -0
  243. package/src/test-utils/golden-diff.ts +197 -0
  244. package/src/test-utils/index.ts +77 -0
  245. package/src/test-utils/integration.ts +81 -0
  246. package/src/test-utils/module-fixtures.ts +468 -0
  247. package/src/test-utils/modules.test.ts +144 -0
  248. package/src/test-utils/modules.ts +183 -0
  249. package/src/test-utils/setup-test-db.ts +90 -0
  250. package/src/test-utils/value-extractor.test.ts +231 -0
  251. package/src/test-utils/value-extractor.ts +228 -0
  252. package/src/types/infrastructure.ts +157 -0
  253. package/src/utils/shell.test.ts +365 -0
  254. package/src/utils/shell.ts +159 -0
  255. package/src/validation/schemas.ts +166 -0
  256. package/src/variables/ansible-resolver.test.ts +142 -0
  257. package/src/variables/ansible-resolver.ts +69 -0
  258. package/src/variables/capability-self-ref.test.ts +220 -0
  259. package/src/variables/context.test.ts +1265 -0
  260. package/src/variables/context.ts +624 -0
  261. package/src/variables/declarative-derivation.test.ts +743 -0
  262. package/src/variables/declarative-derivation.ts +200 -0
  263. package/src/variables/parser.test.ts +231 -0
  264. package/src/variables/parser.ts +76 -0
  265. package/src/variables/resolver.test.ts +458 -0
  266. package/src/variables/resolver.ts +282 -0
  267. package/src/variables/types.ts +59 -0
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Backup creation service.
3
+ * Orchestrates the backup workflow: create temp files, encrypt, upload to storage.
4
+ */
5
+
6
+ import {
7
+ copyFileSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ rmSync,
12
+ statSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { eq } from 'drizzle-orm';
18
+ import { getDbPath } from '../config/paths';
19
+ import { getDb } from '../db/client';
20
+ import { moduleConfigs, modules, secrets as secretsTable } from '../db/schema';
21
+ import { invokeHook } from '../hooks/executor';
22
+ import { createConsoleLogger } from '../hooks/logger';
23
+ import type { ModuleManifest } from '../manifest/schema';
24
+ import { decryptSecret, encryptSecret } from '../secrets/encryption';
25
+ import { getOrCreateMasterKey } from '../secrets/master-key';
26
+ import { shellEscape } from '../utils/shell';
27
+ import { completeBackup, createBackupRecord, failBackup, listBackups } from './backup-metadata';
28
+ import { createStorageProvider, getBackupStorage, getDefaultBackupStorage } from './backup-storage';
29
+
30
+ export interface BackupCreateOptions {
31
+ storageId?: string;
32
+ force?: boolean;
33
+ }
34
+
35
+ export interface BackupCreateResult {
36
+ success: boolean;
37
+ backupId?: string;
38
+ storagePath?: string;
39
+ sizeBytes?: number;
40
+ schemaVersion?: string;
41
+ error?: string;
42
+ }
43
+
44
+ export type BackupSchedule = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'manual';
45
+
46
+ /**
47
+ * Resolve the target storage destination
48
+ */
49
+ export function resolveStorage(storageId?: string) {
50
+ if (storageId) {
51
+ const storage = getBackupStorage(storageId);
52
+ if (!storage) {
53
+ throw new Error(`Storage not found: ${storageId}`);
54
+ }
55
+ if (!storage.verified) {
56
+ throw new Error(
57
+ `Storage '${storage.storageId}' is not verified. Run: celilo storage verify ${storage.storageId}`,
58
+ );
59
+ }
60
+ return storage;
61
+ }
62
+
63
+ const defaultStorage = getDefaultBackupStorage();
64
+ if (!defaultStorage) {
65
+ throw new Error(
66
+ 'No default backup storage configured.\n\nAdd storage first: celilo storage add local',
67
+ );
68
+ }
69
+ if (!defaultStorage.verified) {
70
+ throw new Error(
71
+ `Default storage '${defaultStorage.storageId}' is not verified. Run: celilo storage verify ${defaultStorage.storageId}`,
72
+ );
73
+ }
74
+ return defaultStorage;
75
+ }
76
+
77
+ /**
78
+ * Create a system state backup (Celilo database)
79
+ */
80
+ export async function createSystemStateBackup(
81
+ options: BackupCreateOptions = {},
82
+ ): Promise<BackupCreateResult> {
83
+ const storage = resolveStorage(options.storageId);
84
+ const provider = await createStorageProvider(storage.id);
85
+
86
+ const now = new Date();
87
+ const dateDir = now.toISOString().slice(0, 10);
88
+ const timestamp = now.toISOString().replace(/[:.]/g, '-');
89
+ const storagePath = `${dateDir}/system-${timestamp}.backup`;
90
+
91
+ const record = createBackupRecord({
92
+ moduleId: null,
93
+ storageId: storage.id,
94
+ storagePath,
95
+ backupType: 'system_state',
96
+ });
97
+
98
+ const tempDir = join(tmpdir(), `celilo-backup-${record.id}`);
99
+
100
+ try {
101
+ mkdirSync(tempDir, { recursive: true });
102
+
103
+ // Copy the SQLite database to temp (safe point-in-time copy)
104
+ const dbPath = getDbPath();
105
+ const tempDbPath = join(tempDir, 'celilo.db');
106
+ copyFileSync(dbPath, tempDbPath);
107
+
108
+ // Also copy WAL file if it exists (for consistency)
109
+ const walPath = `${dbPath}-wal`;
110
+ try {
111
+ copyFileSync(walPath, join(tempDir, 'celilo.db-wal'));
112
+ } catch {
113
+ // WAL may not exist — that's fine
114
+ }
115
+
116
+ // Read the database file and encrypt it
117
+ const dbData = readFileSync(tempDbPath);
118
+ const masterKey = await getOrCreateMasterKey();
119
+ const encrypted = encryptSecret(dbData.toString('base64'), masterKey);
120
+
121
+ // Write encrypted payload to temp file
122
+ const encryptedPath = join(tempDir, 'system.enc');
123
+ writeFileSync(encryptedPath, JSON.stringify(encrypted));
124
+
125
+ const encryptedSize = statSync(encryptedPath).size;
126
+
127
+ // Upload to storage
128
+ await provider.upload(encryptedPath, storagePath);
129
+
130
+ // Mark backup as completed
131
+ completeBackup(record.id, {
132
+ sizeBytes: encryptedSize,
133
+ metadata: {
134
+ originalSizeBytes: dbData.length,
135
+ dbPath,
136
+ },
137
+ });
138
+
139
+ return {
140
+ success: true,
141
+ backupId: record.id,
142
+ storagePath,
143
+ sizeBytes: encryptedSize,
144
+ };
145
+ } catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ failBackup(record.id, message);
148
+ return {
149
+ success: false,
150
+ backupId: record.id,
151
+ error: message,
152
+ };
153
+ } finally {
154
+ // Clean up temp directory
155
+ try {
156
+ rmSync(tempDir, { recursive: true, force: true });
157
+ } catch {
158
+ // Best effort cleanup
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Schedule intervals in milliseconds
165
+ */
166
+ const SCHEDULE_INTERVALS: Record<BackupSchedule, number> = {
167
+ hourly: 60 * 60 * 1000,
168
+ daily: 24 * 60 * 60 * 1000,
169
+ weekly: 7 * 24 * 60 * 60 * 1000,
170
+ monthly: 30 * 24 * 60 * 60 * 1000,
171
+ manual: Number.POSITIVE_INFINITY,
172
+ };
173
+
174
+ /**
175
+ * Check if a module is due for backup based on its schedule
176
+ */
177
+ export function isBackupDue(moduleId: string, schedule: BackupSchedule): boolean {
178
+ if (schedule === 'manual') return false;
179
+
180
+ const existing = listBackups({ moduleId, limit: 1 });
181
+ const lastBackup = existing.find(
182
+ (b) => b.moduleId === moduleId && b.status === 'completed' && b.backupType === 'module_data',
183
+ );
184
+
185
+ if (!lastBackup) return true;
186
+
187
+ const elapsed = Date.now() - new Date(lastBackup.startedAt).getTime();
188
+ return elapsed >= SCHEDULE_INTERVALS[schedule];
189
+ }
190
+
191
+ /**
192
+ * Find all installed modules that have an on_backup hook
193
+ */
194
+ export function findBackupEligibleModules(): Array<{
195
+ module: typeof modules.$inferSelect;
196
+ manifest: ModuleManifest;
197
+ }> {
198
+ const db = getDb();
199
+ const allModules = db.select().from(modules).all();
200
+
201
+ const eligible: Array<{
202
+ module: typeof modules.$inferSelect;
203
+ manifest: ModuleManifest;
204
+ }> = [];
205
+
206
+ for (const mod of allModules) {
207
+ if (mod.state !== 'INSTALLED' && mod.state !== 'VERIFIED') continue;
208
+
209
+ const manifest = mod.manifestData as unknown as ModuleManifest;
210
+ if (!manifest.hooks?.on_backup) continue;
211
+
212
+ eligible.push({ module: mod, manifest });
213
+ }
214
+
215
+ return eligible;
216
+ }
217
+
218
+ /**
219
+ * Build config and secret maps for a module (same pattern as health-runner.ts)
220
+ */
221
+ async function buildModuleContext(moduleId: string): Promise<{
222
+ configMap: Record<string, unknown>;
223
+ secretMap: Record<string, string>;
224
+ }> {
225
+ const db = getDb();
226
+
227
+ const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all();
228
+ const configMap: Record<string, unknown> = {};
229
+ for (const c of configs) {
230
+ configMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
231
+ }
232
+
233
+ const secretRecords = db
234
+ .select()
235
+ .from(secretsTable)
236
+ .where(eq(secretsTable.moduleId, moduleId))
237
+ .all();
238
+ const masterKey = await getOrCreateMasterKey();
239
+ const secretMap: Record<string, string> = {};
240
+ for (const s of secretRecords) {
241
+ secretMap[s.name] = decryptSecret(
242
+ { encryptedValue: s.encryptedValue, iv: s.iv, authTag: s.authTag },
243
+ masterKey,
244
+ );
245
+ }
246
+
247
+ return { configMap, secretMap };
248
+ }
249
+
250
+ /**
251
+ * Create a module data backup by executing its on_backup hook
252
+ */
253
+ export async function createModuleBackup(
254
+ moduleId: string,
255
+ options: BackupCreateOptions = {},
256
+ ): Promise<BackupCreateResult> {
257
+ const db = getDb();
258
+ const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get();
259
+ if (!mod) {
260
+ return { success: false, error: `Module not found: ${moduleId}` };
261
+ }
262
+
263
+ const manifest = mod.manifestData as unknown as ModuleManifest;
264
+ const hookDef = manifest.hooks?.on_backup;
265
+ if (!hookDef) {
266
+ return { success: false, error: `Module '${moduleId}' has no on_backup hook` };
267
+ }
268
+
269
+ const storage = resolveStorage(options.storageId);
270
+ const provider = await createStorageProvider(storage.id);
271
+
272
+ const now = new Date();
273
+ const dateDir = now.toISOString().slice(0, 10);
274
+ const timestamp = now.toISOString().replace(/[:.]/g, '-');
275
+ const storagePath = `${dateDir}/${moduleId}-${timestamp}.backup`;
276
+
277
+ const record = createBackupRecord({
278
+ moduleId,
279
+ storageId: storage.id,
280
+ storagePath,
281
+ backupType: 'module_data',
282
+ moduleVersion: manifest.version,
283
+ });
284
+
285
+ const tempDir = join(tmpdir(), `celilo-backup-${record.id}`);
286
+ const backupDir = join(tempDir, 'artifacts');
287
+
288
+ try {
289
+ mkdirSync(backupDir, { recursive: true });
290
+
291
+ // Build context for hook execution
292
+ const { configMap, secretMap } = await buildModuleContext(moduleId);
293
+ const logger = createConsoleLogger(moduleId, 'on_backup');
294
+
295
+ // Execute on_backup hook — it writes artifacts to backupDir
296
+ const hookResult = await invokeHook(
297
+ mod.sourcePath,
298
+ 'on_backup',
299
+ manifest.celilo_contract,
300
+ hookDef,
301
+ { backup_dir: backupDir },
302
+ configMap,
303
+ secretMap,
304
+ logger,
305
+ {
306
+ debug: false,
307
+ },
308
+ );
309
+
310
+ if (!hookResult.success) {
311
+ failBackup(record.id, hookResult.error ?? 'on_backup hook failed');
312
+ return {
313
+ success: false,
314
+ backupId: record.id,
315
+ error: hookResult.error ?? 'on_backup hook failed',
316
+ };
317
+ }
318
+
319
+ // Tar the backup artifacts
320
+ const tarPath = join(tempDir, 'backup.tar');
321
+ const { execSync } = await import('node:child_process');
322
+ execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(backupDir)} .`);
323
+
324
+ // Encrypt the tar
325
+ const tarData = readFileSync(tarPath);
326
+ const masterKey = await getOrCreateMasterKey();
327
+ const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
328
+
329
+ const encryptedPath = join(tempDir, 'backup.tar.enc');
330
+ writeFileSync(encryptedPath, JSON.stringify(encrypted));
331
+
332
+ const encryptedSize = statSync(encryptedPath).size;
333
+
334
+ // Upload to storage
335
+ await provider.upload(encryptedPath, storagePath);
336
+
337
+ // Extract schema_version from hook outputs (optional)
338
+ const schemaVersion =
339
+ typeof hookResult.outputs.schema_version === 'string'
340
+ ? hookResult.outputs.schema_version
341
+ : undefined;
342
+
343
+ // Mark backup as completed
344
+ completeBackup(record.id, {
345
+ sizeBytes: encryptedSize,
346
+ metadata: {
347
+ artifactCount: hookResult.outputs.artifact_count,
348
+ originalSizeBytes: tarData.length,
349
+ },
350
+ schemaVersion,
351
+ });
352
+
353
+ return {
354
+ success: true,
355
+ backupId: record.id,
356
+ storagePath,
357
+ sizeBytes: encryptedSize,
358
+ schemaVersion,
359
+ };
360
+ } catch (error) {
361
+ const message = error instanceof Error ? error.message : String(error);
362
+ failBackup(record.id, message);
363
+ return {
364
+ success: false,
365
+ backupId: record.id,
366
+ error: message,
367
+ };
368
+ } finally {
369
+ try {
370
+ rmSync(tempDir, { recursive: true, force: true });
371
+ } catch {
372
+ // Best effort cleanup
373
+ }
374
+ }
375
+ }
376
+
377
+ export interface ImportBackupOptions {
378
+ storageId?: string;
379
+ schemaVersion?: string;
380
+ name?: string;
381
+ }
382
+
383
+ /**
384
+ * Import a local file as a module backup (bypasses on_backup hook).
385
+ * The file is placed into the backup archive as `db.sqlite`, matching
386
+ * the artifact name produced by the on_backup hook.
387
+ */
388
+ export async function importModuleBackup(
389
+ filePath: string,
390
+ moduleId: string,
391
+ options: ImportBackupOptions = {},
392
+ ): Promise<BackupCreateResult> {
393
+ const db = getDb();
394
+ const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get();
395
+ if (!mod) {
396
+ return { success: false, error: `Module not found: ${moduleId}` };
397
+ }
398
+
399
+ if (!existsSync(filePath)) {
400
+ return { success: false, error: `File not found: ${filePath}` };
401
+ }
402
+
403
+ const manifest = mod.manifestData as unknown as ModuleManifest;
404
+ const storage = resolveStorage(options.storageId);
405
+ const provider = await createStorageProvider(storage.id);
406
+
407
+ const now = new Date();
408
+ const dateDir = now.toISOString().slice(0, 10);
409
+ const timestamp = now.toISOString().replace(/[:.]/g, '-');
410
+ const storagePath = `${dateDir}/${moduleId}-${timestamp}.backup`;
411
+
412
+ const record = createBackupRecord({
413
+ moduleId,
414
+ storageId: storage.id,
415
+ storagePath,
416
+ backupType: 'module_data',
417
+ moduleVersion: manifest.version,
418
+ });
419
+
420
+ const tempDir = join(tmpdir(), `celilo-backup-${record.id}`);
421
+ const artifactDir = join(tempDir, 'artifacts');
422
+
423
+ try {
424
+ mkdirSync(artifactDir, { recursive: true });
425
+
426
+ // Copy the file as db.sqlite (matching on_backup hook output)
427
+ const destPath = join(artifactDir, 'db.sqlite');
428
+ copyFileSync(filePath, destPath);
429
+
430
+ // If the module has an on_backup_analyze hook, invoke it to extract metadata
431
+ let analyzedSchemaVersion: string | undefined = options.schemaVersion;
432
+ let analyzedMetadata: Record<string, unknown> = {
433
+ artifactCount: '1',
434
+ originalSizeBytes: 0,
435
+ importedFrom: filePath,
436
+ };
437
+
438
+ const analyzeHook = manifest.hooks?.on_backup_analyze;
439
+ if (analyzeHook) {
440
+ const { configMap, secretMap } = await buildModuleContext(moduleId);
441
+ const logger = createConsoleLogger(moduleId, 'on_backup_analyze');
442
+
443
+ const analyzeResult = await invokeHook(
444
+ mod.sourcePath,
445
+ 'on_backup_analyze',
446
+ manifest.celilo_contract,
447
+ analyzeHook,
448
+ { artifact_path: destPath },
449
+ configMap,
450
+ secretMap,
451
+ logger,
452
+ {
453
+ debug: false,
454
+ },
455
+ );
456
+
457
+ if (analyzeResult.success) {
458
+ // Extract schema_version from hook outputs if available
459
+ if (typeof analyzeResult.outputs.schema_version === 'string') {
460
+ analyzedSchemaVersion = analyzeResult.outputs.schema_version;
461
+ }
462
+ // Merge all hook outputs into metadata
463
+ analyzedMetadata = {
464
+ ...analyzedMetadata,
465
+ ...analyzeResult.outputs,
466
+ importedFrom: filePath,
467
+ };
468
+ } else {
469
+ // Hook failed - log warning but continue with import
470
+ console.warn(
471
+ `Warning: on_backup_analyze hook failed: ${analyzeResult.error}. Continuing with manual metadata.`,
472
+ );
473
+ }
474
+ }
475
+
476
+ // Tar the artifacts
477
+ const tarPath = join(tempDir, 'backup.tar');
478
+ const { execSync } = await import('node:child_process');
479
+ execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(artifactDir)} .`);
480
+
481
+ // Encrypt the tar
482
+ const tarData = readFileSync(tarPath);
483
+ const masterKey = await getOrCreateMasterKey();
484
+ const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
485
+
486
+ const encryptedPath = join(tempDir, 'backup.tar.enc');
487
+ writeFileSync(encryptedPath, JSON.stringify(encrypted));
488
+
489
+ const encryptedSize = statSync(encryptedPath).size;
490
+
491
+ // Upload to storage
492
+ await provider.upload(encryptedPath, storagePath);
493
+
494
+ // Mark backup as completed
495
+ completeBackup(record.id, {
496
+ sizeBytes: encryptedSize,
497
+ metadata: {
498
+ ...analyzedMetadata,
499
+ originalSizeBytes: tarData.length,
500
+ },
501
+ schemaVersion: analyzedSchemaVersion,
502
+ });
503
+
504
+ // Set human-readable name if provided
505
+ if (options.name) {
506
+ const { updateBackupName } = await import('./backup-metadata');
507
+ updateBackupName(record.id, options.name);
508
+ }
509
+
510
+ return {
511
+ success: true,
512
+ backupId: record.id,
513
+ storagePath,
514
+ sizeBytes: encryptedSize,
515
+ schemaVersion: analyzedSchemaVersion,
516
+ };
517
+ } catch (error) {
518
+ const message = error instanceof Error ? error.message : String(error);
519
+ failBackup(record.id, message);
520
+ return {
521
+ success: false,
522
+ backupId: record.id,
523
+ error: message,
524
+ };
525
+ } finally {
526
+ try {
527
+ rmSync(tempDir, { recursive: true, force: true });
528
+ } catch {
529
+ // Best effort cleanup
530
+ }
531
+ }
532
+ }