@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,73 @@
1
+ /**
2
+ * Drift check: every active module's committed `celilo/types.d.ts`
3
+ * must match what `generateModuleTypes()` would produce from its
4
+ * current `manifest.yml`.
5
+ *
6
+ * This is the CI-level backstop for `celilo module types check`.
7
+ * If a manifest changes `variables.owns`/`variables.imports` but the
8
+ * committed types file is not regenerated, this test fails.
9
+ *
10
+ * Scope: `modules/<id>/manifest.yml` at the repo root. Archived
11
+ * modules (`modules/archive/**`) and scratch extract dirs
12
+ * (`modules/.tmp-*`) are excluded — they are not active modules.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { readFileSync } from 'node:fs';
17
+ import { dirname, join, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { Glob } from 'bun';
20
+ import { validateManifest } from '../manifest/validate';
21
+ import { generateModuleTypes } from './module-types-generator';
22
+
23
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
24
+ const MODULES_DIR = join(REPO_ROOT, 'modules');
25
+
26
+ function findActiveManifests(): string[] {
27
+ const glob = new Glob('*/manifest.yml');
28
+ const results: string[] = [];
29
+ for (const match of glob.scanSync({ cwd: MODULES_DIR, onlyFiles: true })) {
30
+ results.push(join(MODULES_DIR, match));
31
+ }
32
+ return results.sort();
33
+ }
34
+
35
+ describe('module types drift check', () => {
36
+ const manifests = findActiveManifests();
37
+
38
+ test('discovers at least one active module', () => {
39
+ expect(manifests.length).toBeGreaterThan(0);
40
+ });
41
+
42
+ for (const manifestPath of manifests) {
43
+ const moduleDir = dirname(manifestPath);
44
+ const moduleId = moduleDir.split('/').pop() ?? moduleDir;
45
+
46
+ test(`${moduleId}: committed types.d.ts matches generated output`, () => {
47
+ const yaml = readFileSync(manifestPath, 'utf-8');
48
+ const result = validateManifest(yaml);
49
+ if (!result.success) {
50
+ const errorMessages = result.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
51
+ throw new Error(`Manifest validation failed for ${moduleId}:\n${errorMessages}`);
52
+ }
53
+
54
+ const expected = generateModuleTypes(result.data);
55
+ const typesPath = join(moduleDir, 'celilo', 'types.d.ts');
56
+
57
+ let actual: string;
58
+ try {
59
+ actual = readFileSync(typesPath, 'utf-8');
60
+ } catch {
61
+ throw new Error(
62
+ `Missing committed types file: ${typesPath}\nRun: celilo module types generate ${moduleDir}`,
63
+ );
64
+ }
65
+
66
+ if (actual !== expected) {
67
+ throw new Error(
68
+ `Types file is stale: ${typesPath}\nIt does not match what would be generated from the current manifest.yml.\nRun: celilo module types generate ${moduleDir}`,
69
+ );
70
+ }
71
+ });
72
+ }
73
+ });
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Tests for the module types generator.
3
+ *
4
+ * These exercise pure codegen — no filesystem I/O. Each test builds a
5
+ * ModuleManifest object in-memory and asserts on the generated string.
6
+ */
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import type { ModuleManifest } from '../manifest/schema';
10
+ import {
11
+ generateModuleTypes,
12
+ moduleIdToPascalCase,
13
+ variableTypeToTs,
14
+ } from './module-types-generator';
15
+
16
+ function baseManifest(overrides: Partial<ModuleManifest> = {}): ModuleManifest {
17
+ return {
18
+ celilo_contract: '1.0',
19
+ id: 'test-module',
20
+ name: 'Test Module',
21
+ version: '1.0.0',
22
+ requires: { capabilities: [] },
23
+ provides: { capabilities: [] },
24
+ variables: { owns: [], imports: [] },
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ describe('moduleIdToPascalCase', () => {
30
+ test('converts single-word IDs', () => {
31
+ expect(moduleIdToPascalCase('lunacycle')).toBe('Lunacycle');
32
+ });
33
+
34
+ test('converts kebab-case IDs', () => {
35
+ expect(moduleIdToPascalCase('dns-external')).toBe('DnsExternal');
36
+ expect(moduleIdToPascalCase('my-fancy-app')).toBe('MyFancyApp');
37
+ });
38
+
39
+ test('handles empty segments defensively', () => {
40
+ expect(moduleIdToPascalCase('a--b')).toBe('AB');
41
+ });
42
+
43
+ test('preserves numeric characters', () => {
44
+ expect(moduleIdToPascalCase('caddy-v2')).toBe('CaddyV2');
45
+ });
46
+ });
47
+
48
+ describe('variableTypeToTs', () => {
49
+ test('maps primitives', () => {
50
+ expect(variableTypeToTs('string')).toBe('string');
51
+ expect(variableTypeToTs('boolean')).toBe('boolean');
52
+ expect(variableTypeToTs('integer')).toBe('number');
53
+ expect(variableTypeToTs('number')).toBe('number');
54
+ });
55
+
56
+ test('maps array to unknown[]', () => {
57
+ expect(variableTypeToTs('array')).toBe('unknown[]');
58
+ });
59
+
60
+ test('maps object to Record<string, unknown>', () => {
61
+ expect(variableTypeToTs('object')).toBe('Record<string, unknown>');
62
+ });
63
+ });
64
+
65
+ describe('generateModuleTypes', () => {
66
+ test('emits a file header and empty interface for a manifest with no variables', () => {
67
+ const out = generateModuleTypes(baseManifest({ id: 'empty', name: 'Empty Module' }));
68
+ expect(out).toContain('// Generated from manifest.yml');
69
+ expect(out).toContain('Do not edit by hand');
70
+ expect(out).toContain('export interface EmptyConfig {');
71
+ expect(out).toContain('(No variables declared — module has no typed config surface)');
72
+ });
73
+
74
+ test('produces the right interface name from a kebab-case module ID', () => {
75
+ const out = generateModuleTypes(baseManifest({ id: 'dns-external', name: 'DNS External' }));
76
+ expect(out).toContain('export interface DnsExternalConfig {');
77
+ });
78
+
79
+ test('renders required fields as non-optional', () => {
80
+ const out = generateModuleTypes(
81
+ baseManifest({
82
+ id: 'test',
83
+ name: 'Test',
84
+ variables: {
85
+ owns: [
86
+ {
87
+ name: 'hostname',
88
+ type: 'string',
89
+ required: true,
90
+ source: 'user',
91
+ },
92
+ ],
93
+ imports: [],
94
+ },
95
+ }),
96
+ );
97
+ expect(out).toContain('hostname: string;');
98
+ expect(out).not.toContain('hostname?: string;');
99
+ });
100
+
101
+ test('renders optional fields as optional when not required and no default', () => {
102
+ const out = generateModuleTypes(
103
+ baseManifest({
104
+ id: 'test',
105
+ name: 'Test',
106
+ variables: {
107
+ owns: [
108
+ {
109
+ name: 'hostname',
110
+ type: 'string',
111
+ required: false,
112
+ source: 'user',
113
+ },
114
+ ],
115
+ imports: [],
116
+ },
117
+ }),
118
+ );
119
+ expect(out).toContain('hostname?: string;');
120
+ });
121
+
122
+ test('treats not-required + default as guaranteed (non-optional)', () => {
123
+ const out = generateModuleTypes(
124
+ baseManifest({
125
+ id: 'test',
126
+ name: 'Test',
127
+ variables: {
128
+ owns: [
129
+ {
130
+ name: 'app_port',
131
+ type: 'integer',
132
+ required: false,
133
+ default: 3000,
134
+ source: 'user',
135
+ },
136
+ ],
137
+ imports: [],
138
+ },
139
+ }),
140
+ );
141
+ expect(out).toContain('app_port: number;');
142
+ expect(out).not.toContain('app_port?: number;');
143
+ });
144
+
145
+ test('maps every primitive type correctly', () => {
146
+ const out = generateModuleTypes(
147
+ baseManifest({
148
+ id: 'test',
149
+ name: 'Test',
150
+ variables: {
151
+ owns: [
152
+ { name: 'str', type: 'string', required: true, source: 'user' },
153
+ { name: 'num', type: 'number', required: true, source: 'user' },
154
+ { name: 'int', type: 'integer', required: true, source: 'user' },
155
+ { name: 'bool', type: 'boolean', required: true, source: 'user' },
156
+ { name: 'arr', type: 'array', required: true, source: 'user' },
157
+ { name: 'obj', type: 'object', required: true, source: 'user' },
158
+ ],
159
+ imports: [],
160
+ },
161
+ }),
162
+ );
163
+ expect(out).toContain('str: string;');
164
+ expect(out).toContain('num: number;');
165
+ expect(out).toContain('int: number;');
166
+ expect(out).toContain('bool: boolean;');
167
+ expect(out).toContain('arr: unknown[];');
168
+ expect(out).toContain('obj: Record<string, unknown>;');
169
+ });
170
+
171
+ test('emits JSDoc comments from variable descriptions', () => {
172
+ const out = generateModuleTypes(
173
+ baseManifest({
174
+ id: 'test',
175
+ name: 'Test',
176
+ variables: {
177
+ owns: [
178
+ {
179
+ name: 'hostname',
180
+ type: 'string',
181
+ required: true,
182
+ source: 'user',
183
+ description: 'Container hostname for DNS resolution',
184
+ },
185
+ ],
186
+ imports: [],
187
+ },
188
+ }),
189
+ );
190
+ expect(out).toContain('/** Container hostname for DNS resolution */');
191
+ });
192
+
193
+ test('renders imported capability variables as unknown with source-annotated JSDoc', () => {
194
+ const out = generateModuleTypes(
195
+ baseManifest({
196
+ id: 'test',
197
+ name: 'Test',
198
+ variables: {
199
+ owns: [],
200
+ imports: [
201
+ {
202
+ name: 'dns_nameserver',
203
+ source: 'capability',
204
+ from: 'dns_external.nameserver',
205
+ },
206
+ ],
207
+ },
208
+ }),
209
+ );
210
+ expect(out).toContain('Imported capability variables');
211
+ expect(out).toContain('dns_nameserver: unknown;');
212
+ expect(out).toContain('Imported from capability: dns_external.nameserver');
213
+ });
214
+
215
+ test('separates owns and imports sections with a blank line', () => {
216
+ const out = generateModuleTypes(
217
+ baseManifest({
218
+ id: 'test',
219
+ name: 'Test',
220
+ variables: {
221
+ owns: [{ name: 'hostname', type: 'string', required: true, source: 'user' }],
222
+ imports: [{ name: 'dns_ns', source: 'capability', from: 'dns_external.nameserver' }],
223
+ },
224
+ }),
225
+ );
226
+ expect(out).toContain('Module-owned variables');
227
+ expect(out).toContain('Imported capability variables');
228
+ });
229
+
230
+ test('includes the module name and description in the interface JSDoc', () => {
231
+ const out = generateModuleTypes(
232
+ baseManifest({
233
+ id: 'lunacycle',
234
+ name: 'LunaCycle',
235
+ description: 'Family task management',
236
+ }),
237
+ );
238
+ expect(out).toContain('Configuration shape for the LunaCycle module');
239
+ expect(out).toContain('Family task management');
240
+ });
241
+
242
+ test('escapes */ sequences in descriptions to avoid breaking the JSDoc', () => {
243
+ const out = generateModuleTypes(
244
+ baseManifest({
245
+ id: 'test',
246
+ name: 'Test',
247
+ variables: {
248
+ owns: [
249
+ {
250
+ name: 'weird',
251
+ type: 'string',
252
+ required: true,
253
+ source: 'user',
254
+ description: 'A field with */ in the description',
255
+ },
256
+ ],
257
+ imports: [],
258
+ },
259
+ }),
260
+ );
261
+ expect(out).not.toContain('*/ in the description');
262
+ expect(out).toContain('* / in the description');
263
+ });
264
+
265
+ test('output is deterministic across multiple runs', () => {
266
+ const manifest = baseManifest({
267
+ id: 'lunacycle',
268
+ name: 'LunaCycle',
269
+ variables: {
270
+ owns: [
271
+ { name: 'hostname', type: 'string', required: true, source: 'user' },
272
+ { name: 'app_port', type: 'integer', required: false, default: 3000, source: 'user' },
273
+ ],
274
+ imports: [],
275
+ },
276
+ });
277
+ const first = generateModuleTypes(manifest);
278
+ const second = generateModuleTypes(manifest);
279
+ const third = generateModuleTypes(manifest);
280
+ expect(first).toBe(second);
281
+ expect(second).toBe(third);
282
+ });
283
+
284
+ test('output ends with a trailing newline', () => {
285
+ const out = generateModuleTypes(baseManifest());
286
+ expect(out.endsWith('\n')).toBe(true);
287
+ });
288
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Module Types Generator
3
+ *
4
+ * Pure codegen: given a validated ModuleManifest, produces the content of
5
+ * `<module>/celilo/types.d.ts` — a TypeScript declaration file exposing
6
+ * a typed `<ModuleName>Config` interface derived from the manifest's
7
+ * `variables.owns` and `variables.imports` arrays.
8
+ *
9
+ * This file is intentionally I/O-free. The CLI command in
10
+ * `cli/commands/module-types.ts` handles reading manifests and writing
11
+ * output files; this module is responsible for the translation only, so
12
+ * unit tests can exercise every branch without touching the filesystem.
13
+ *
14
+ * See `design/TECHNICAL_DESIGN_HOOK_API_V2.md` D2 for the design rationale.
15
+ */
16
+
17
+ import type { ModuleManifest, VariableDeclare, VariableImport } from '../manifest/schema';
18
+
19
+ /**
20
+ * Convert a kebab-case module ID to a PascalCase type name.
21
+ *
22
+ * "lunacycle" → "Lunacycle"
23
+ * "dns-external" → "DnsExternal"
24
+ * "my-fancy-app" → "MyFancyApp"
25
+ */
26
+ export function moduleIdToPascalCase(id: string): string {
27
+ return id
28
+ .split('-')
29
+ .map((part) => (part.length === 0 ? '' : part[0].toUpperCase() + part.slice(1)))
30
+ .join('');
31
+ }
32
+
33
+ /**
34
+ * Translate a variable's declared `type:` field into a TypeScript type
35
+ * expression. Per HOOK_API_V2 D2, arrays and objects get generic
36
+ * unknown-element types for now; richer item-level typing can be added
37
+ * later without breaking any committed `types.d.ts`.
38
+ */
39
+ export function variableTypeToTs(type: VariableDeclare['type']): string {
40
+ switch (type) {
41
+ case 'string':
42
+ return 'string';
43
+ case 'integer':
44
+ case 'number':
45
+ return 'number';
46
+ case 'boolean':
47
+ return 'boolean';
48
+ case 'array':
49
+ return 'unknown[]';
50
+ case 'object':
51
+ return 'Record<string, unknown>';
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Determine whether a declared variable is guaranteed to have a value at
57
+ * hook execution time. A variable is guaranteed if:
58
+ * - It's marked `required: true`, OR
59
+ * - It has a `default:` value (the default guarantees presence).
60
+ *
61
+ * `infrastructure` sources are treated as guaranteed when the variable is
62
+ * marked required; Celilo's infrastructure-variable-resolver populates
63
+ * them before hooks run and fails fast if it can't.
64
+ */
65
+ function isVariableGuaranteed(variable: VariableDeclare): boolean {
66
+ if (variable.required) return true;
67
+ if (variable.default !== undefined) return true;
68
+ return false;
69
+ }
70
+
71
+ function isImportGuaranteed(_variable: VariableImport): boolean {
72
+ // Imports are sourced from another module's capability. The capability
73
+ // must be declared in requires.capabilities (enforced by
74
+ // validateVariableSources), and at hook invocation time Celilo's
75
+ // resolver populates the value. We treat imports as guaranteed —
76
+ // Phase 3 will add an explicit runtime pre-flight check.
77
+ return true;
78
+ }
79
+
80
+ /**
81
+ * Escape a string so it can appear safely inside a JSDoc block comment.
82
+ * Converts sequences that would terminate the comment.
83
+ */
84
+ function escapeJsDoc(text: string): string {
85
+ return text.replace(/\*\//g, '* /');
86
+ }
87
+
88
+ function formatJsDocLine(description: string | undefined): string[] {
89
+ if (!description) return [];
90
+ return [` /** ${escapeJsDoc(description)} */`];
91
+ }
92
+
93
+ /**
94
+ * Render a single interface field: optional JSDoc + name + optionality + type.
95
+ */
96
+ function renderField(
97
+ name: string,
98
+ type: string,
99
+ optional: boolean,
100
+ description: string | undefined,
101
+ ): string[] {
102
+ const doc = formatJsDocLine(description);
103
+ const optionalMark = optional ? '?' : '';
104
+ return [...doc, ` ${name}${optionalMark}: ${type};`];
105
+ }
106
+
107
+ /**
108
+ * Sort variables deterministically so regenerating against the same
109
+ * manifest always produces byte-identical output. Manifest author order
110
+ * is the canonical order — stable across runs, meaningful to readers.
111
+ * `variables.owns` is already ordered by the YAML parser; we just
112
+ * preserve it.
113
+ */
114
+
115
+ /**
116
+ * Generate the full `<module>/celilo/types.d.ts` content for a manifest.
117
+ *
118
+ * Deterministic: calling this twice with the same input produces
119
+ * byte-identical output, so the drift check can compare against the
120
+ * committed file.
121
+ */
122
+ export function generateModuleTypes(manifest: ModuleManifest): string {
123
+ const typeName = `${moduleIdToPascalCase(manifest.id)}Config`;
124
+ const lines: string[] = [];
125
+
126
+ lines.push('// Generated from manifest.yml by `celilo module types generate`.');
127
+ lines.push('// Do not edit by hand. Run the command again after changing `variables.owns`.');
128
+ lines.push('// CI enforces this file stays in sync via `celilo module types check`.');
129
+ lines.push('');
130
+ lines.push('/**');
131
+ lines.push(` * Configuration shape for the ${manifest.name} module.`);
132
+ if (manifest.description) {
133
+ lines.push(' *');
134
+ for (const descLine of manifest.description.split('\n')) {
135
+ lines.push(` * ${descLine}`);
136
+ }
137
+ }
138
+ lines.push(' *');
139
+ lines.push(' * Fields are derived from `variables.owns` and `variables.imports` in the');
140
+ lines.push(' * module manifest. Optional fields (marked with `?`) correspond to variables');
141
+ lines.push(' * that are neither `required: true` nor have a `default:` value.');
142
+ lines.push(' */');
143
+ lines.push(`export interface ${typeName} {`);
144
+
145
+ const ownsFields: string[] = [];
146
+ const importsFields: string[] = [];
147
+
148
+ for (const variable of manifest.variables.owns) {
149
+ const tsType = variableTypeToTs(variable.type);
150
+ const optional = !isVariableGuaranteed(variable);
151
+ const rendered = renderField(variable.name, tsType, optional, variable.description);
152
+ ownsFields.push(...rendered);
153
+ }
154
+
155
+ for (const imp of manifest.variables.imports) {
156
+ // Imports don't carry type info in the manifest schema — they're
157
+ // raw references to capability data. Treat as `unknown` so consumers
158
+ // cast as needed. A future enhancement could look up the capability's
159
+ // `data_schema` to infer a more precise type.
160
+ const optional = !isImportGuaranteed(imp);
161
+ const rendered = renderField(
162
+ imp.name,
163
+ 'unknown',
164
+ optional,
165
+ `Imported from ${imp.source}: ${imp.from}`,
166
+ );
167
+ importsFields.push(...rendered);
168
+ }
169
+
170
+ if (ownsFields.length > 0) {
171
+ lines.push(' // Module-owned variables (from variables.owns)');
172
+ lines.push(...ownsFields);
173
+ }
174
+
175
+ if (importsFields.length > 0) {
176
+ if (ownsFields.length > 0) lines.push('');
177
+ lines.push(' // Imported capability variables (from variables.imports)');
178
+ lines.push(...importsFields);
179
+ }
180
+
181
+ if (ownsFields.length === 0 && importsFields.length === 0) {
182
+ lines.push(' // (No variables declared — module has no typed config surface)');
183
+ }
184
+
185
+ lines.push('}');
186
+ lines.push('');
187
+
188
+ return lines.join('\n');
189
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Proxmox State Recovery Service
3
+ *
4
+ * Handles state drift scenarios where Terraform state and module_configs are out of sync.
5
+ * This typically occurs when containers are deleted outside Terraform and then recreated.
6
+ */
7
+
8
+ import { readFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { and, eq } from 'drizzle-orm';
11
+ import { log } from '../cli/prompts';
12
+ import type { DbClient } from '../db/client';
13
+ import { moduleConfigs } from '../db/schema';
14
+
15
+ /**
16
+ * Terraform state file structure (subset we care about)
17
+ */
18
+ interface TerraformState {
19
+ resources?: Array<{
20
+ type: string;
21
+ name: string;
22
+ instances?: Array<{
23
+ attributes?: {
24
+ vmid?: number;
25
+ network?: Array<{
26
+ ip?: string;
27
+ }>;
28
+ };
29
+ }>;
30
+ }>;
31
+ }
32
+
33
+ /**
34
+ * Ensure module_configs has vmid and container_ip from Terraform state
35
+ * This recovers from state drift scenarios where container was deleted/recreated
36
+ *
37
+ * @param moduleId - Module identifier
38
+ * @param terraformDir - Terraform working directory
39
+ * @param db - Database connection
40
+ */
41
+ export async function ensureProxmoxConfigFromState(
42
+ moduleId: string,
43
+ terraformDir: string,
44
+ db: DbClient,
45
+ ): Promise<void> {
46
+ // Check if vmid and container_ip already exist
47
+ const existingVmid = await db
48
+ .select()
49
+ .from(moduleConfigs)
50
+ .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, 'vmid')))
51
+ .get();
52
+
53
+ const existingContainerIp = await db
54
+ .select()
55
+ .from(moduleConfigs)
56
+ .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, 'container_ip')))
57
+ .get();
58
+
59
+ // If both exist, no recovery needed
60
+ if (existingVmid && existingContainerIp) {
61
+ return;
62
+ }
63
+
64
+ // Read Terraform state file
65
+ const statePath = join(terraformDir, 'terraform.tfstate');
66
+ let stateContent: string;
67
+ try {
68
+ stateContent = await readFile(statePath, 'utf-8');
69
+ } catch (error) {
70
+ throw new Error(
71
+ `Failed to read Terraform state for recovery: ${error instanceof Error ? error.message : 'Unknown error'}`,
72
+ );
73
+ }
74
+
75
+ let state: TerraformState;
76
+ try {
77
+ state = JSON.parse(stateContent) as TerraformState;
78
+ } catch (error) {
79
+ throw new Error(
80
+ `Failed to parse Terraform state: ${error instanceof Error ? error.message : 'Unknown error'}`,
81
+ );
82
+ }
83
+
84
+ // Find proxmox_lxc resource
85
+ const lxcResource = state.resources?.find((r) => r.type === 'proxmox_lxc');
86
+ if (!lxcResource || !lxcResource.instances || lxcResource.instances.length === 0) {
87
+ throw new Error('No proxmox_lxc resource found in Terraform state');
88
+ }
89
+
90
+ const attributes = lxcResource.instances[0].attributes;
91
+ if (!attributes) {
92
+ throw new Error('No attributes found in proxmox_lxc resource');
93
+ }
94
+
95
+ const vmid = attributes.vmid;
96
+ const containerIp = attributes.network?.[0]?.ip;
97
+
98
+ if (!vmid) {
99
+ throw new Error('vmid not found in Terraform state');
100
+ }
101
+
102
+ if (!containerIp) {
103
+ throw new Error('container IP not found in Terraform state');
104
+ }
105
+
106
+ // Store in module_configs (recovery)
107
+ log.warn(' Recovering vmid and container_ip from Terraform state...');
108
+
109
+ if (!existingVmid) {
110
+ await db
111
+ .insert(moduleConfigs)
112
+ .values({
113
+ moduleId,
114
+ key: 'vmid',
115
+ value: vmid.toString(),
116
+ })
117
+ .onConflictDoUpdate({
118
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
119
+ set: { value: vmid.toString() },
120
+ })
121
+ .run();
122
+ }
123
+
124
+ if (!existingContainerIp) {
125
+ await db
126
+ .insert(moduleConfigs)
127
+ .values({
128
+ moduleId,
129
+ key: 'container_ip',
130
+ value: containerIp,
131
+ })
132
+ .onConflictDoUpdate({
133
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
134
+ set: { value: containerIp },
135
+ })
136
+ .run();
137
+ }
138
+
139
+ log.success(` Recovered: vmid=${vmid}, container_ip=${containerIp}`);
140
+ }