@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,290 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { eq } from 'drizzle-orm';
5
+ import { type DbClient, createDbClient } from '../db/client';
6
+ import { moduleBuilds, modules } from '../db/schema';
7
+ import { buildModuleFromSource, getModuleBuildStatus, verifyArtifactsExist } from './module-build';
8
+
9
+ const TEST_DB_PATH = './test-module-build.db';
10
+ const TEST_MODULE_DIR = './test-module-build-dir';
11
+
12
+ describe('Module Build Service', () => {
13
+ let db: DbClient;
14
+
15
+ beforeEach(() => {
16
+ db = createDbClient({ path: TEST_DB_PATH });
17
+
18
+ // Create tables
19
+ db.$client.run(`
20
+ CREATE TABLE IF NOT EXISTS modules (
21
+ id TEXT PRIMARY KEY,
22
+ name TEXT NOT NULL,
23
+ version TEXT NOT NULL,
24
+ description TEXT,
25
+ state TEXT NOT NULL DEFAULT 'IMPORTED',
26
+ manifest_data TEXT NOT NULL,
27
+ source_path TEXT NOT NULL,
28
+ imported_at INTEGER NOT NULL DEFAULT (unixepoch()),
29
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
30
+ error_message TEXT
31
+ )
32
+ `);
33
+
34
+ db.$client.run(`
35
+ CREATE TABLE IF NOT EXISTS module_builds (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ module_id TEXT NOT NULL,
38
+ version TEXT NOT NULL,
39
+ built_at INTEGER NOT NULL DEFAULT (unixepoch()),
40
+ artifacts TEXT NOT NULL,
41
+ environment TEXT,
42
+ status TEXT NOT NULL,
43
+ build_log TEXT,
44
+ FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
45
+ )
46
+ `);
47
+ });
48
+
49
+ afterEach(async () => {
50
+ db.$client.close();
51
+
52
+ if (existsSync(TEST_DB_PATH)) {
53
+ await rm(TEST_DB_PATH);
54
+ }
55
+ const walPath = `${TEST_DB_PATH}-wal`;
56
+ const shmPath = `${TEST_DB_PATH}-shm`;
57
+ if (existsSync(walPath)) {
58
+ await rm(walPath);
59
+ }
60
+ if (existsSync(shmPath)) {
61
+ await rm(shmPath);
62
+ }
63
+
64
+ // Clean up test module directory
65
+ if (existsSync(TEST_MODULE_DIR)) {
66
+ await rm(TEST_MODULE_DIR, { recursive: true });
67
+ }
68
+ });
69
+
70
+ describe('buildModuleFromSource', () => {
71
+ test('should return error if module not found', async () => {
72
+ const result = await buildModuleFromSource('nonexistent', db);
73
+
74
+ expect(result.success).toBe(false);
75
+ expect(result.error).toContain('Module not found');
76
+ });
77
+
78
+ test('should return error if module has no build section', async () => {
79
+ db.insert(modules)
80
+ .values({
81
+ id: 'no-build',
82
+ name: 'No Build Module',
83
+ version: '1.0.0',
84
+ sourcePath: '/test/no-build',
85
+ manifestData: {
86
+ id: 'no-build',
87
+ name: 'No Build Module',
88
+ version: '1.0.0',
89
+ },
90
+ })
91
+ .run();
92
+
93
+ const result = await buildModuleFromSource('no-build', db);
94
+
95
+ expect(result.success).toBe(false);
96
+ expect(result.error).toContain('does not have a build section');
97
+ });
98
+
99
+ test('should detect Nix environment from flake.nix', async () => {
100
+ // Create test module directory with flake.nix
101
+ await mkdir(TEST_MODULE_DIR, { recursive: true });
102
+ await mkdir(`${TEST_MODULE_DIR}/build`, { recursive: true });
103
+ await writeFile(`${TEST_MODULE_DIR}/flake.nix`, '# Nix flake');
104
+ await writeFile(
105
+ `${TEST_MODULE_DIR}/build/playbook.yml`,
106
+ `---
107
+ - name: Test build
108
+ hosts: localhost
109
+ gather_facts: false
110
+ tasks:
111
+ - name: Debug message
112
+ ansible.builtin.debug:
113
+ msg: "Nix build test"
114
+ `,
115
+ );
116
+
117
+ db.insert(modules)
118
+ .values({
119
+ id: 'nix-module',
120
+ name: 'Nix Module',
121
+ version: '1.0.0',
122
+ sourcePath: TEST_MODULE_DIR,
123
+ manifestData: {
124
+ id: 'nix-module',
125
+ name: 'Nix Module',
126
+ version: '1.0.0',
127
+ build: {
128
+ script: 'build/playbook.yml',
129
+ },
130
+ },
131
+ })
132
+ .run();
133
+
134
+ // Mock console.log to capture output
135
+ const consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {});
136
+
137
+ const _result = await buildModuleFromSource('nix-module', db);
138
+
139
+ // Verify Nix detection or fallback message was logged
140
+ // If nix is available: "ℹ Entering Nix environment"
141
+ // If nix not available: "⚠️ flake.nix detected but nix command not available"
142
+ const logCalls = consoleLogSpy.mock.calls.map((call) => call[0]);
143
+ const hasNixMessage = logCalls.some(
144
+ (msg) => msg.includes('Nix environment') || msg.includes('flake.nix detected'),
145
+ );
146
+ expect(hasNixMessage).toBe(true);
147
+
148
+ consoleLogSpy.mockRestore();
149
+
150
+ // Build result depends on whether Nix is installed
151
+ // We're just testing that detection happens (message logged)
152
+ }, 10000); // 10 second timeout
153
+
154
+ test('should record build metadata in database', async () => {
155
+ await mkdir(TEST_MODULE_DIR, { recursive: true });
156
+ await mkdir(`${TEST_MODULE_DIR}/build`, { recursive: true });
157
+ await writeFile(
158
+ `${TEST_MODULE_DIR}/build/playbook.yml`,
159
+ `---
160
+ - name: Quick test build
161
+ hosts: localhost
162
+ gather_facts: false
163
+ tasks:
164
+ - name: Echo message
165
+ ansible.builtin.debug:
166
+ msg: "Build test"
167
+ `,
168
+ );
169
+
170
+ db.insert(modules)
171
+ .values({
172
+ id: 'record-test',
173
+ name: 'Record Test',
174
+ version: '1.0.0',
175
+ sourcePath: TEST_MODULE_DIR,
176
+ manifestData: {
177
+ id: 'record-test',
178
+ name: 'Record Test',
179
+ version: '1.0.0',
180
+ build: {
181
+ script: 'build/playbook.yml',
182
+ },
183
+ },
184
+ })
185
+ .run();
186
+
187
+ const result = await buildModuleFromSource('record-test', db);
188
+
189
+ // Build should succeed (simple debug task)
190
+ expect(result.success).toBe(true);
191
+
192
+ // Verify build metadata was recorded
193
+ const buildRecord = await db
194
+ .select()
195
+ .from(moduleBuilds)
196
+ .where(eq(moduleBuilds.moduleId, 'record-test'))
197
+ .get();
198
+
199
+ expect(buildRecord).toBeDefined();
200
+ expect(buildRecord?.moduleId).toBe('record-test');
201
+ expect(buildRecord?.version).toBe('1.0.0');
202
+ expect(buildRecord?.status).toBe('success');
203
+ expect(buildRecord?.environment).toBe('system'); // No flake.nix
204
+ }, 10000); // 10 second timeout for ansible execution
205
+ });
206
+
207
+ describe('getModuleBuildStatus', () => {
208
+ test('should return null if module never built', async () => {
209
+ const status = await getModuleBuildStatus('never-built', db);
210
+
211
+ expect(status).toBeNull();
212
+ });
213
+
214
+ test('should return latest build status', async () => {
215
+ // Create module first (for foreign key)
216
+ db.insert(modules)
217
+ .values({
218
+ id: 'test-module',
219
+ name: 'Test Module',
220
+ version: '1.0.0',
221
+ sourcePath: '/test/module',
222
+ manifestData: {
223
+ id: 'test-module',
224
+ name: 'Test Module',
225
+ version: '1.0.0',
226
+ },
227
+ })
228
+ .run();
229
+
230
+ db.insert(moduleBuilds)
231
+ .values({
232
+ moduleId: 'test-module',
233
+ version: '1.0.0',
234
+ artifacts: ['/path/to/artifact'],
235
+ status: 'success',
236
+ buildLog: 'Build completed',
237
+ environment: 'nix',
238
+ })
239
+ .run();
240
+
241
+ const status = await getModuleBuildStatus('test-module', db);
242
+
243
+ expect(status).toBeDefined();
244
+ expect(status?.status).toBe('success');
245
+ expect(status?.artifacts).toEqual(['/path/to/artifact']);
246
+ expect(status?.buildLog).toBe('Build completed');
247
+ });
248
+ });
249
+
250
+ describe('verifyArtifactsExist', () => {
251
+ test('should return true if all artifacts exist', async () => {
252
+ // Create test artifact
253
+ await mkdir(TEST_MODULE_DIR, { recursive: true });
254
+ const artifactPath = `${TEST_MODULE_DIR}/artifact.txt`;
255
+ await writeFile(artifactPath, 'test');
256
+
257
+ const result = verifyArtifactsExist([artifactPath]);
258
+
259
+ expect(result).toBe(true);
260
+ });
261
+
262
+ test('should return false if any artifact missing', async () => {
263
+ const result = verifyArtifactsExist(['/nonexistent/artifact.txt']);
264
+
265
+ expect(result).toBe(false);
266
+ });
267
+
268
+ test('should handle multiple artifacts', async () => {
269
+ await mkdir(TEST_MODULE_DIR, { recursive: true });
270
+ const artifact1 = `${TEST_MODULE_DIR}/artifact1.txt`;
271
+ const artifact2 = `${TEST_MODULE_DIR}/artifact2.txt`;
272
+ await writeFile(artifact1, 'test1');
273
+ await writeFile(artifact2, 'test2');
274
+
275
+ const result = verifyArtifactsExist([artifact1, artifact2]);
276
+
277
+ expect(result).toBe(true);
278
+ });
279
+
280
+ test('should return false if any artifact in list is missing', async () => {
281
+ await mkdir(TEST_MODULE_DIR, { recursive: true });
282
+ const artifact1 = `${TEST_MODULE_DIR}/exists.txt`;
283
+ await writeFile(artifact1, 'test');
284
+
285
+ const result = verifyArtifactsExist([artifact1, '/nonexistent/missing.txt']);
286
+
287
+ expect(result).toBe(false);
288
+ });
289
+ });
290
+ });
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Module Build Service
3
+ *
4
+ * Executes module build scripts (Ansible playbooks) in Nix environments
5
+ * to compile custom software binaries before deployment.
6
+ *
7
+ * Example: Caddy with RFC2136 DNS provider
8
+ */
9
+
10
+ import { existsSync, statSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { eq } from 'drizzle-orm';
13
+ import type { DbClient } from '../db/client';
14
+ import { type BuildStatus, moduleBuilds, modules } from '../db/schema';
15
+ import type { ModuleManifest } from '../manifest/schema';
16
+
17
+ /**
18
+ * Build result
19
+ */
20
+ export interface BuildResult {
21
+ success: boolean;
22
+ artifacts?: string[];
23
+ buildLog?: string;
24
+ error?: string;
25
+ }
26
+
27
+ /**
28
+ * Build artifact from manifest (simple path string)
29
+ */
30
+ export type BuildArtifact = string;
31
+
32
+ /**
33
+ * Build status record from database
34
+ */
35
+ export interface BuildStatusRecord {
36
+ status: BuildStatus;
37
+ artifacts: string[];
38
+ builtAt: Date;
39
+ buildLog?: string;
40
+ }
41
+
42
+ /**
43
+ * Detect if module uses Nix environment
44
+ * Policy function - checks for flake.nix existence
45
+ *
46
+ * @param modulePath - Path to module directory
47
+ * @returns True if flake.nix exists
48
+ */
49
+ function detectNixEnvironment(modulePath: string): boolean {
50
+ const flakePath = `${modulePath}/flake.nix`;
51
+ return existsSync(flakePath);
52
+ }
53
+
54
+ /**
55
+ * Check if nix command is available
56
+ * Policy function - validates nix availability
57
+ *
58
+ * @returns True if nix command can be executed
59
+ */
60
+ async function isNixAvailable(): Promise<boolean> {
61
+ const { spawn } = await import('node:child_process');
62
+
63
+ return new Promise((resolve) => {
64
+ const child = spawn('nix', ['--version'], {
65
+ stdio: 'ignore',
66
+ });
67
+
68
+ child.on('close', (code) => {
69
+ resolve(code === 0);
70
+ });
71
+
72
+ child.on('error', () => {
73
+ resolve(false);
74
+ });
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Execute build playbook with streaming progress
80
+ * Execution function - runs ansible-playbook with fuel-gauge progress indicator
81
+ *
82
+ * @param modulePath - Path to module directory
83
+ * @param playbookPath - Path to build playbook (relative to module)
84
+ * @param useNix - Whether to run inside Nix environment
85
+ * @returns Build result with output
86
+ */
87
+ async function executeBuildCommand(
88
+ modulePath: string,
89
+ commandStr: string,
90
+ useNix: boolean,
91
+ ): Promise<{ success: boolean; output: string; error?: string }> {
92
+ const { executeBuildWithProgress } = await import('./build-stream');
93
+
94
+ let command: string;
95
+ let args: string[];
96
+
97
+ if (useNix) {
98
+ command = 'nix';
99
+ args = ['develop', '--command', 'bash', '-c', commandStr];
100
+ } else {
101
+ command = 'bash';
102
+ args = ['-c', commandStr];
103
+ }
104
+
105
+ const result = await executeBuildWithProgress({
106
+ command,
107
+ args,
108
+ cwd: modulePath,
109
+ });
110
+
111
+ return { success: result.success, output: result.output, error: result.error };
112
+ }
113
+
114
+ async function executeBuildScript(
115
+ modulePath: string,
116
+ scriptPath: string,
117
+ useNix: boolean,
118
+ ): Promise<{ success: boolean; output: string; error?: string }> {
119
+ const fullScriptPath = `${modulePath}/${scriptPath}`;
120
+
121
+ if (!existsSync(fullScriptPath)) {
122
+ throw new Error(`Build script not found: ${scriptPath}`);
123
+ }
124
+
125
+ const { executeBuildWithProgress } = await import('./build-stream');
126
+
127
+ const isShellScript = scriptPath.endsWith('.sh');
128
+
129
+ let command: string;
130
+ let args: string[];
131
+
132
+ if (useNix) {
133
+ const innerCommand = isShellScript ? 'bash' : 'ansible-playbook';
134
+ command = 'nix';
135
+ args = ['develop', '--command', innerCommand, scriptPath];
136
+ } else if (isShellScript) {
137
+ command = 'bash';
138
+ args = [scriptPath];
139
+ } else {
140
+ command = 'ansible-playbook';
141
+ args = [scriptPath];
142
+ }
143
+
144
+ const result = await executeBuildWithProgress({
145
+ command,
146
+ args,
147
+ cwd: modulePath,
148
+ });
149
+
150
+ return { success: result.success, output: result.output, error: result.error };
151
+ }
152
+
153
+ /**
154
+ * Verify build artifacts exist
155
+ * Policy function - validates file existence
156
+ *
157
+ * @param artifacts - List of artifact paths from manifest (relative to module directory)
158
+ * @param modulePath - Module directory path
159
+ * @returns Array of [artifactPath, exists, size]
160
+ */
161
+ function verifyBuildArtifacts(
162
+ artifacts: BuildArtifact[],
163
+ modulePath: string,
164
+ ): Array<{ path: string; exists: boolean; size?: number }> {
165
+ const results: Array<{ path: string; exists: boolean; size?: number }> = [];
166
+
167
+ for (const artifactPath of artifacts) {
168
+ // Resolve relative to module directory
169
+ const fullPath = join(modulePath, artifactPath);
170
+ const exists = existsSync(fullPath);
171
+ let size: number | undefined;
172
+
173
+ if (exists) {
174
+ const stats = statSync(fullPath);
175
+ size = stats.size;
176
+ }
177
+
178
+ results.push({ path: fullPath, exists, size });
179
+ }
180
+
181
+ return results;
182
+ }
183
+
184
+ /**
185
+ * Format bytes to human-readable size
186
+ * Policy function - formatting
187
+ *
188
+ * @param bytes - Size in bytes
189
+ * @returns Formatted string (e.g., "48.2 MB")
190
+ */
191
+ function formatBytes(bytes: number): string {
192
+ if (bytes < 1024) return `${bytes} B`;
193
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
194
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
195
+ }
196
+
197
+ /**
198
+ * Record build metadata in database
199
+ * Execution function - database write
200
+ *
201
+ * @param moduleId - Module identifier
202
+ * @param version - Module version
203
+ * @param artifacts - List of artifact paths
204
+ * @param status - Build status
205
+ * @param buildLog - Build output log
206
+ * @param environment - Build environment (nix or system)
207
+ * @param db - Database client
208
+ */
209
+ async function recordBuildMetadata(
210
+ moduleId: string,
211
+ version: string,
212
+ artifacts: string[],
213
+ status: BuildStatus,
214
+ buildLog: string,
215
+ environment: 'nix' | 'system' | null,
216
+ db: DbClient,
217
+ ): Promise<void> {
218
+ await db.insert(moduleBuilds).values({
219
+ moduleId,
220
+ version,
221
+ artifacts,
222
+ status,
223
+ buildLog,
224
+ environment,
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Build module from source
230
+ * Orchestrator function - coordinates build workflow
231
+ *
232
+ * @param moduleId - Module identifier
233
+ * @param db - Database client
234
+ * @returns Build result
235
+ */
236
+ export async function buildModuleFromSource(moduleId: string, db: DbClient): Promise<BuildResult> {
237
+ // Fetch module from database
238
+ const module = await db.select().from(modules).where(eq(modules.id, moduleId)).get();
239
+
240
+ if (!module) {
241
+ return {
242
+ success: false,
243
+ error: `Module not found: ${moduleId}`,
244
+ };
245
+ }
246
+
247
+ const manifest = module.manifestData as ModuleManifest;
248
+
249
+ // Check if module has build section
250
+ if (!manifest.build) {
251
+ return {
252
+ success: false,
253
+ error: `Module ${moduleId} does not have a build section in manifest`,
254
+ };
255
+ }
256
+
257
+ const { command, script, artifacts } = manifest.build;
258
+ const modulePath = module.sourcePath;
259
+
260
+ if (!command && !script) {
261
+ return {
262
+ success: false,
263
+ error: `Module ${moduleId} build section must specify either "command" or "script"`,
264
+ };
265
+ }
266
+
267
+ // Detect Nix environment and verify availability
268
+ const nixDetected = detectNixEnvironment(modulePath);
269
+ const nixAvailable = nixDetected ? await isNixAvailable() : false;
270
+ const useNix = nixDetected && nixAvailable;
271
+ const environment: 'nix' | 'system' = useNix ? 'nix' : 'system';
272
+
273
+ if (useNix) {
274
+ console.log('ℹ Entering Nix environment (flake.nix detected)...');
275
+ } else if (nixDetected && !nixAvailable) {
276
+ console.log('⚠️ flake.nix detected but nix command not available - using system Ansible');
277
+ }
278
+
279
+ try {
280
+ // Execute build script with streaming progress
281
+ const buildResult = command
282
+ ? await executeBuildCommand(modulePath, command, useNix)
283
+ : await executeBuildScript(modulePath, script as string, useNix);
284
+
285
+ // Check if build command failed
286
+ if (!buildResult.success) {
287
+ const errorMsg = buildResult.error || 'Build command failed';
288
+
289
+ // Record failed build
290
+ await recordBuildMetadata(
291
+ moduleId,
292
+ manifest.version,
293
+ [],
294
+ 'failed',
295
+ buildResult.output,
296
+ environment,
297
+ db,
298
+ );
299
+
300
+ return {
301
+ success: false,
302
+ error: errorMsg,
303
+ buildLog: buildResult.output,
304
+ };
305
+ }
306
+
307
+ const buildLog = buildResult.output.trim();
308
+
309
+ // Verify artifacts
310
+ if (artifacts && artifacts.length > 0) {
311
+ console.log('Verifying build artifacts...');
312
+ const verification = verifyBuildArtifacts(artifacts, modulePath);
313
+ const allExist = verification.every((v) => v.exists);
314
+
315
+ if (!allExist) {
316
+ const missing = verification.filter((v) => !v.exists);
317
+ const errorMsg = `Build failed: Missing artifacts:\n${missing.map((m) => ` - ${m.path}`).join('\n')}`;
318
+
319
+ // Record failed build
320
+ await recordBuildMetadata(
321
+ moduleId,
322
+ manifest.version,
323
+ [],
324
+ 'failed',
325
+ `${buildLog}\n\n${errorMsg}`,
326
+ environment,
327
+ db,
328
+ );
329
+
330
+ return {
331
+ success: false,
332
+ error: errorMsg,
333
+ buildLog,
334
+ };
335
+ }
336
+
337
+ // Display artifact info
338
+ for (const result of verification) {
339
+ const sizeStr = result.size ? ` (${formatBytes(result.size)})` : '';
340
+ console.log(`✓ Artifact verified: ${result.path}${sizeStr}`);
341
+ }
342
+
343
+ // Record successful build
344
+ const artifactPaths = verification.map((v) => v.path);
345
+ await recordBuildMetadata(
346
+ moduleId,
347
+ manifest.version,
348
+ artifactPaths,
349
+ 'success',
350
+ buildLog,
351
+ environment,
352
+ db,
353
+ );
354
+
355
+ return {
356
+ success: true,
357
+ artifacts: artifactPaths,
358
+ buildLog,
359
+ };
360
+ }
361
+
362
+ // No artifacts defined, just record success
363
+ await recordBuildMetadata(moduleId, manifest.version, [], 'success', buildLog, environment, db);
364
+
365
+ return {
366
+ success: true,
367
+ artifacts: [],
368
+ buildLog,
369
+ };
370
+ } catch (error) {
371
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
372
+ const buildLog = `Build failed: ${errorMsg}`;
373
+
374
+ // Record failed build
375
+ await recordBuildMetadata(moduleId, manifest.version, [], 'failed', buildLog, environment, db);
376
+
377
+ return {
378
+ success: false,
379
+ error: errorMsg,
380
+ buildLog,
381
+ };
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Get module build status
387
+ * Execution function - database query
388
+ *
389
+ * @param moduleId - Module identifier
390
+ * @param db - Database client
391
+ * @returns Build status record or null if never built
392
+ */
393
+ export async function getModuleBuildStatus(
394
+ moduleId: string,
395
+ db: DbClient,
396
+ ): Promise<BuildStatusRecord | null> {
397
+ const build = await db
398
+ .select()
399
+ .from(moduleBuilds)
400
+ .where(eq(moduleBuilds.moduleId, moduleId))
401
+ .orderBy(moduleBuilds.builtAt)
402
+ .limit(1)
403
+ .get();
404
+
405
+ if (!build) {
406
+ return null;
407
+ }
408
+
409
+ return {
410
+ status: build.status,
411
+ artifacts: build.artifacts as string[],
412
+ builtAt: new Date(build.builtAt),
413
+ buildLog: build.buildLog || undefined,
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Verify build artifacts still exist
419
+ * Policy function - validates current state of artifacts
420
+ *
421
+ * @param artifactPaths - List of full artifact paths (from database)
422
+ * @returns True if all artifacts exist
423
+ */
424
+ export function verifyArtifactsExist(artifactPaths: string[]): boolean {
425
+ for (const path of artifactPaths) {
426
+ if (!existsSync(path)) {
427
+ return false;
428
+ }
429
+ }
430
+ return true;
431
+ }