@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,208 @@
1
+ /**
2
+ * Fuel-Gauge Tests
3
+ * Tests pure rendering functions without terminal interaction
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { FuelGauge } from './fuel-gauge';
8
+
9
+ describe('FuelGauge', () => {
10
+ describe('buildProgressBar', () => {
11
+ test('builds bar with gradient at position 0 moving right', () => {
12
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
13
+ const bar = gauge.buildProgressBar(0, 20, 1, false);
14
+
15
+ // Gradient (4 chars) at position 0, moving right: ░▒▓█
16
+ expect(bar).toBe('░▒▓█················');
17
+ });
18
+
19
+ test('builds bar with gradient at position 0 moving left', () => {
20
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
21
+ const bar = gauge.buildProgressBar(0, 20, -1, false);
22
+
23
+ // Gradient (4 chars) at position 0, moving left: █▓▒░
24
+ expect(bar).toBe('█▓▒░················');
25
+ });
26
+
27
+ test('builds bar with gradient at middle position moving right', () => {
28
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
29
+ const bar = gauge.buildProgressBar(5, 20, 1, false);
30
+
31
+ // Gradient at position 5, moving right
32
+ expect(bar).toBe('·····░▒▓█···········');
33
+ });
34
+
35
+ test('builds bar with gradient at middle position moving left', () => {
36
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
37
+ const bar = gauge.buildProgressBar(5, 20, -1, false);
38
+
39
+ // Gradient at position 5, moving left
40
+ expect(bar).toBe('·····█▓▒░···········');
41
+ });
42
+
43
+ test('builds bar with gradient at end position', () => {
44
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
45
+ const bar = gauge.buildProgressBar(16, 20, 1, false);
46
+
47
+ // Gradient at position 16 (near right edge)
48
+ expect(bar).toBe('················░▒▓█');
49
+ });
50
+
51
+ test('handles narrow bar width', () => {
52
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
53
+ const bar = gauge.buildProgressBar(0, 15, 1, false);
54
+
55
+ expect(bar).toHaveLength(15);
56
+ expect(bar).toMatch(/^[░▒▓█]+·+$/);
57
+ });
58
+
59
+ test('handles wide bar width', () => {
60
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
61
+ const bar = gauge.buildProgressBar(20, 100, 1, false);
62
+
63
+ expect(bar).toHaveLength(100);
64
+ expect(bar).toMatch(/^·+[░▒▓█]+·+$/);
65
+ });
66
+
67
+ test('pulses front block when waiting (pulse=true, moving right)', () => {
68
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
69
+
70
+ // pulseState starts at 0, front block (right-most) alternates █ -> ▓
71
+ const bar1 = gauge.buildProgressBar(0, 20, 1, true);
72
+ expect(bar1).toBe('░▒▓█················'); // pulseState % 2 = 0, shows solid █
73
+
74
+ // After pulseState increments to 1, front block shows next level
75
+ // (Note: pulseState is private, we can't easily test the actual alternation)
76
+ });
77
+
78
+ test('pulses front block when waiting (pulse=true, moving left)', () => {
79
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
80
+
81
+ // pulseState starts at 0, front block (left-most) alternates █ -> ▓
82
+ const bar1 = gauge.buildProgressBar(0, 20, -1, true);
83
+ expect(bar1).toBe('█▓▒░················'); // pulseState % 2 = 0, shows solid █
84
+ });
85
+ });
86
+
87
+ describe('formatOutputLines', () => {
88
+ test('returns last N lines', () => {
89
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
90
+ const lines = ['Line 1', 'Line 2', 'Line 3', 'Line 4', 'Line 5'];
91
+
92
+ const formatted = gauge.formatOutputLines(lines, 3);
93
+
94
+ expect(formatted).toHaveLength(3);
95
+ expect(formatted[0]).toContain('Line 3');
96
+ expect(formatted[1]).toContain('Line 4');
97
+ expect(formatted[2]).toContain('Line 5');
98
+ });
99
+
100
+ test('pads lines with indentation', () => {
101
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
102
+ const lines = ['Test output'];
103
+
104
+ const formatted = gauge.formatOutputLines(lines, 1);
105
+
106
+ expect(formatted[0]).toBe(' Test output');
107
+ });
108
+
109
+ test('truncates long lines', () => {
110
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
111
+ const longLine = 'a'.repeat(200);
112
+
113
+ const formatted = gauge.formatOutputLines([longLine], 1);
114
+
115
+ // Should be truncated with ...
116
+ expect(formatted[0].length).toBeLessThan(200);
117
+ expect(formatted[0]).toMatch(/\.\.\.$/);
118
+ });
119
+
120
+ test('handles empty lines array', () => {
121
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
122
+
123
+ const formatted = gauge.formatOutputLines([], 3);
124
+
125
+ expect(formatted).toHaveLength(0);
126
+ });
127
+
128
+ test('handles fewer lines than max', () => {
129
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
130
+ const lines = ['Line 1', 'Line 2'];
131
+
132
+ const formatted = gauge.formatOutputLines(lines, 5);
133
+
134
+ // Should return all available lines
135
+ expect(formatted).toHaveLength(2);
136
+ });
137
+ });
138
+
139
+ describe('addOutput and getOutput', () => {
140
+ test('stores output lines', () => {
141
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
142
+
143
+ gauge.addOutput('Line 1');
144
+ gauge.addOutput('Line 2');
145
+ gauge.addOutput('Line 3');
146
+
147
+ const output = gauge.getOutput();
148
+ expect(output).toEqual(['Line 1', 'Line 2', 'Line 3']);
149
+ });
150
+
151
+ test('strips ANSI codes from output', () => {
152
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
153
+
154
+ // Add line with ANSI color codes
155
+ gauge.addOutput('\x1b[31mRed text\x1b[0m');
156
+ gauge.addOutput('\x1b[36mCyan text\x1b[0m');
157
+
158
+ const output = gauge.getOutput();
159
+ expect(output[0]).toBe('Red text');
160
+ expect(output[1]).toBe('Cyan text');
161
+ });
162
+
163
+ test('limits stored lines to 100', () => {
164
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
165
+
166
+ // Add 150 lines
167
+ for (let i = 0; i < 150; i++) {
168
+ gauge.addOutput(`Line ${i}`);
169
+ }
170
+
171
+ const output = gauge.getOutput();
172
+ expect(output).toHaveLength(100);
173
+ // Should have last 100 lines (50-149)
174
+ expect(output[0]).toBe('Line 50');
175
+ expect(output[99]).toBe('Line 149');
176
+ });
177
+ });
178
+
179
+ describe('start and stop', () => {
180
+ test('start sets running state in test mode', () => {
181
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
182
+
183
+ gauge.start();
184
+
185
+ // Should not throw and should be testable
186
+ expect(gauge).toBeDefined();
187
+ });
188
+
189
+ test('stop clears running state in test mode', () => {
190
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
191
+
192
+ gauge.start();
193
+ gauge.stop(true);
194
+
195
+ // Should complete without error
196
+ expect(gauge).toBeDefined();
197
+ });
198
+
199
+ test('handles stop before start', () => {
200
+ const gauge = new FuelGauge('Test', { skipAnimation: true });
201
+
202
+ // Should not throw
203
+ gauge.stop(true);
204
+
205
+ expect(gauge).toBeDefined();
206
+ });
207
+ });
208
+ });
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Fuel-Gauge Progress Indicator
3
+ *
4
+ * Custom Cylon-style progress bar with scrolling output preview
5
+ * Features:
6
+ * - Back-and-forth animated bar (like Battlestar Galactica Cylon eye)
7
+ * - 3-4 lines of scrolling greyed-out output above the bar
8
+ * - On success: clears output, shows only success message
9
+ * - On error: shows last 8 lines of output for debugging
10
+ */
11
+
12
+ import { stdout } from 'node:process';
13
+ import * as p from '@clack/prompts';
14
+
15
+ /**
16
+ * ANSI color codes for terminal output
17
+ */
18
+ const colors = {
19
+ cyan: (text: string) => `\x1b[36m${text}\x1b[0m`,
20
+ dim: (text: string) => `\x1b[2m${text}\x1b[0m`,
21
+ mutedPurple: (text: string) => `\x1b[38;5;238m${text}\x1b[0m`, // 256-color very dark gray-purple (barely visible)
22
+ reset: '\x1b[0m',
23
+ };
24
+
25
+ export interface FuelGaugeOptions {
26
+ output?: NodeJS.WriteStream;
27
+ skipAnimation?: boolean; // For testing
28
+ onBackground?: () => void; // Callback when ESC pressed
29
+ }
30
+
31
+ /**
32
+ * Fuel-Gauge progress indicator with Cylon-style animation
33
+ *
34
+ * Rendering strategy: builds the entire frame as a single string and
35
+ * writes it in one output.write() call to prevent flicker. Uses
36
+ * "move cursor up N lines" + overwrite rather than clear-then-draw.
37
+ */
38
+ export class FuelGauge {
39
+ private title: string;
40
+ private outputLines: string[] = [];
41
+ private barPosition = 0;
42
+ private barDirection = 1; // 1 = right, -1 = left
43
+ private intervalId: NodeJS.Timeout | null = null;
44
+ private readonly maxDisplayLines = 4; // Show 3-4 lines of output
45
+ private readonly errorDisplayLines = 100; // Show full output on error
46
+ private readonly output: NodeJS.WriteStream;
47
+ private readonly skipAnimation: boolean;
48
+ private readonly onBackground?: () => void;
49
+ private running = false;
50
+ private hasNewOutput = false; // Track if new output arrived
51
+ private pulseState = 0; // Track pulse animation state (0-3)
52
+ private keyListener?: (chunk: Buffer) => void;
53
+ private sigintHandler?: () => void;
54
+ private alreadyCleanedUp = false; // Track if we've already cleaned up terminal
55
+ private linesDrawn = 0; // Track exactly how many lines were drawn last frame
56
+
57
+ constructor(title: string, options: FuelGaugeOptions = {}) {
58
+ this.title = title;
59
+ this.output = options.output || stdout;
60
+ this.skipAnimation = options.skipAnimation || false;
61
+ this.onBackground = options.onBackground;
62
+ }
63
+
64
+ /**
65
+ * Start the fuel-gauge animation
66
+ */
67
+ start(): void {
68
+ if (this.skipAnimation) {
69
+ // Test mode: just track state
70
+ this.running = true;
71
+ return;
72
+ }
73
+
74
+ this.running = true;
75
+
76
+ // Hide cursor
77
+ this.output.write('\x1B[?25l');
78
+
79
+ // Set up SIGINT handler to restore terminal on Ctrl+C (not in test mode)
80
+ if (!this.skipAnimation) {
81
+ this.sigintHandler = () => {
82
+ if (this.alreadyCleanedUp) {
83
+ process.exit(130);
84
+ }
85
+ this.alreadyCleanedUp = true;
86
+
87
+ // Restore terminal: clear gauge area and show cursor
88
+ this.writeClearSequence();
89
+ this.output.write('\x1B[?25h');
90
+
91
+ if (this.intervalId) {
92
+ clearInterval(this.intervalId);
93
+ this.intervalId = null;
94
+ }
95
+ if (this.keyListener && process.stdin.isTTY) {
96
+ process.stdin.off('data', this.keyListener);
97
+ process.stdin.setRawMode(false);
98
+ process.stdin.pause();
99
+ }
100
+
101
+ process.exit(130);
102
+ };
103
+ process.on('SIGINT', this.sigintHandler);
104
+ }
105
+
106
+ // Set up keyboard listener for ESC key and Ctrl+C
107
+ if (this.onBackground && process.stdin.isTTY) {
108
+ process.stdin.setRawMode(true);
109
+ process.stdin.resume();
110
+
111
+ this.keyListener = (chunk: Buffer) => {
112
+ if (chunk[0] === 0x03) {
113
+ // Ctrl+C — raw mode swallows SIGINT, handle manually
114
+ if (this.sigintHandler) this.sigintHandler();
115
+ } else if (chunk[0] === 0x1b && chunk.length === 1) {
116
+ this.background();
117
+ }
118
+ };
119
+
120
+ process.stdin.on('data', this.keyListener);
121
+ }
122
+
123
+ // Draw initial frame
124
+ this.writeFrame();
125
+
126
+ // Animate at 100ms intervals
127
+ this.intervalId = setInterval(() => {
128
+ this.render();
129
+ }, 100);
130
+ }
131
+
132
+ /**
133
+ * Add output line (will be shown in scrolling preview)
134
+ */
135
+ addOutput(line: string): void {
136
+ const cleaned = this.stripAnsi(line);
137
+ this.outputLines.push(cleaned);
138
+
139
+ if (this.outputLines.length > 100) {
140
+ this.outputLines.shift();
141
+ }
142
+
143
+ this.hasNewOutput = true;
144
+ }
145
+
146
+ /**
147
+ * Background the animation (user pressed ESC)
148
+ */
149
+ private background(): void {
150
+ if (!this.running) return;
151
+
152
+ this.cleanup();
153
+ p.log.info(`${this.title} (backgrounded)`);
154
+
155
+ if (this.onBackground) {
156
+ this.onBackground();
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Stop the animation and finalize
162
+ */
163
+ stop(success: boolean): void {
164
+ if (!this.running) return;
165
+
166
+ this.running = false;
167
+
168
+ if (this.skipAnimation) {
169
+ return;
170
+ }
171
+
172
+ this.cleanup();
173
+
174
+ if (success) {
175
+ p.log.success(this.title);
176
+ } else {
177
+ p.log.error(this.title);
178
+ console.log('');
179
+ console.log(colors.dim('Last output:'));
180
+ const errorLines = this.outputLines.slice(-this.errorDisplayLines);
181
+ for (const line of errorLines) {
182
+ if (line.trim()) {
183
+ console.log(colors.dim(` ${line}`));
184
+ }
185
+ }
186
+ console.log('');
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Clean up resources (keyboard listener, animation, cursor)
192
+ */
193
+ private cleanup(): void {
194
+ if (this.alreadyCleanedUp) return;
195
+ this.alreadyCleanedUp = true;
196
+
197
+ if (this.intervalId) {
198
+ clearInterval(this.intervalId);
199
+ this.intervalId = null;
200
+ }
201
+
202
+ if (this.sigintHandler) {
203
+ process.off('SIGINT', this.sigintHandler);
204
+ this.sigintHandler = undefined;
205
+ }
206
+
207
+ if (this.keyListener && process.stdin.isTTY) {
208
+ process.stdin.off('data', this.keyListener);
209
+ process.stdin.setRawMode(false);
210
+ process.stdin.pause();
211
+ this.keyListener = undefined;
212
+ }
213
+
214
+ if (!this.skipAnimation) {
215
+ this.writeClearSequence();
216
+ this.output.write('\x1B[?25h');
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Get all captured output
222
+ */
223
+ getOutput(): string[] {
224
+ return [...this.outputLines];
225
+ }
226
+
227
+ /**
228
+ * Build progress bar string (pure function for testing)
229
+ */
230
+ buildProgressBar(position: number, width: number, direction: number, pulse: boolean): string {
231
+ const gradient = ['█', '▓', '▒', '░'];
232
+ const barEmpty = '·';
233
+ const barLength = gradient.length;
234
+
235
+ let bar = '';
236
+ for (let i = 0; i < width; i++) {
237
+ const offset = i - position;
238
+
239
+ if (offset >= 0 && offset < barLength) {
240
+ let charIndex: number;
241
+ let isFrontBlock = false;
242
+
243
+ if (direction === 1) {
244
+ charIndex = barLength - 1 - offset;
245
+ isFrontBlock = offset === barLength - 1;
246
+ } else {
247
+ charIndex = offset;
248
+ isFrontBlock = offset === 0;
249
+ }
250
+
251
+ if (pulse && isFrontBlock) {
252
+ const pulseToggle = Math.floor(this.pulseState / 6) % 2;
253
+ bar += gradient[pulseToggle];
254
+ } else {
255
+ bar += gradient[charIndex];
256
+ }
257
+ } else {
258
+ bar += barEmpty;
259
+ }
260
+ }
261
+ return bar;
262
+ }
263
+
264
+ /**
265
+ * Format output lines for display (pure function for testing)
266
+ */
267
+ formatOutputLines(lines: string[], maxLines: number): string[] {
268
+ const recentLines = lines.slice(-maxLines);
269
+ const termWidth = this.output.columns || 80;
270
+ const maxLen = termWidth - 4;
271
+
272
+ return recentLines.map((line) => {
273
+ const truncated = line.length > maxLen ? `${line.slice(0, maxLen - 3)}...` : line;
274
+ return ` ${truncated}`;
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Strip ANSI codes from text
280
+ */
281
+ private stripAnsi(text: string): string {
282
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC character needed for ANSI code matching
283
+ return text.replace(/\u001b\[[0-9;]*m/g, '');
284
+ }
285
+
286
+ /**
287
+ * Render one frame (called by animation loop)
288
+ */
289
+ private render(): void {
290
+ const termWidth = this.output.columns || 80;
291
+ const barWidth = Math.max(termWidth - 4, 20);
292
+
293
+ if (this.hasNewOutput) {
294
+ this.barPosition += this.barDirection;
295
+ if (this.barPosition >= barWidth - 4) {
296
+ this.barDirection = -1;
297
+ } else if (this.barPosition <= 0) {
298
+ this.barDirection = 1;
299
+ }
300
+ }
301
+
302
+ this.pulseState++;
303
+
304
+ // Build frame, move cursor up over previous frame, write new frame
305
+ // All in a single output.write() to prevent flicker
306
+ const frame = this.buildFrame();
307
+ const moveUp = this.linesDrawn > 0 ? `\x1b[${this.linesDrawn}A\r` : '';
308
+ this.output.write(moveUp + frame);
309
+
310
+ this.hasNewOutput = false;
311
+ }
312
+
313
+ /**
314
+ * Write the initial frame (no cursor movement needed)
315
+ */
316
+ private writeFrame(): void {
317
+ const frame = this.buildFrame();
318
+ this.output.write(frame);
319
+ }
320
+
321
+ /**
322
+ * Write a sequence to clear the gauge area (for cleanup/stop)
323
+ */
324
+ private writeClearSequence(): void {
325
+ if (this.linesDrawn <= 0) return;
326
+ const termWidth = this.output.columns || 80;
327
+ const blankLine = ' '.repeat(termWidth);
328
+ // Move up, then overwrite each line with blanks
329
+ let seq = `\x1b[${this.linesDrawn}A\r`;
330
+ for (let i = 0; i < this.linesDrawn; i++) {
331
+ seq += `${blankLine}\n`;
332
+ }
333
+ // Move back up to where we started
334
+ seq += `\x1b[${this.linesDrawn}A\r`;
335
+ this.output.write(seq);
336
+ this.linesDrawn = 0;
337
+ }
338
+
339
+ /**
340
+ * Build the entire frame as a single string.
341
+ * Each line is padded to terminal width to overwrite previous content.
342
+ */
343
+ private buildFrame(): string {
344
+ const termWidth = this.output.columns || 80;
345
+ let lineCount = 0;
346
+ let frame = '';
347
+
348
+ const pad = (s: string, visibleLen: number) => {
349
+ // Pad with spaces to fill the terminal width, clearing any leftover chars
350
+ const remaining = Math.max(0, termWidth - visibleLen);
351
+ return s + ' '.repeat(remaining);
352
+ };
353
+
354
+ // Title line
355
+ const titleText = `▸ ${this.title}`;
356
+ frame += `${pad(colors.cyan(titleText), titleText.length + 2)}\n`;
357
+ lineCount++;
358
+
359
+ // Output preview lines
360
+ const displayLines = this.formatOutputLines(this.outputLines, this.maxDisplayLines);
361
+ for (const line of displayLines) {
362
+ frame += `${pad(colors.dim(line), line.length)}\n`;
363
+ lineCount++;
364
+ }
365
+
366
+ // Padding lines
367
+ const paddingLines = this.maxDisplayLines - displayLines.length;
368
+ for (let i = 0; i < paddingLines; i++) {
369
+ frame += `${pad('', 0)}\n`;
370
+ lineCount++;
371
+ }
372
+
373
+ // Progress bar
374
+ const hintText = '(esc to bg; ^C to cancel)';
375
+ const barWidth = Math.max(termWidth - 4 - hintText.length - 2, 20);
376
+ const shouldPulse = !this.hasNewOutput;
377
+ const plainBar = this.buildProgressBar(
378
+ this.barPosition,
379
+ barWidth,
380
+ this.barDirection,
381
+ shouldPulse,
382
+ );
383
+
384
+ // Colorize bar
385
+ let coloredBar = ' ';
386
+ let visibleBarLen = 2; // leading spaces
387
+ for (let i = 0; i < plainBar.length; i++) {
388
+ const char = plainBar[i];
389
+ if (char === '·') {
390
+ coloredBar += colors.mutedPurple('·');
391
+ } else {
392
+ coloredBar += colors.cyan(char);
393
+ }
394
+ visibleBarLen++;
395
+ }
396
+ coloredBar += ` ${colors.mutedPurple(hintText)}`;
397
+ visibleBarLen += 2 + hintText.length;
398
+
399
+ frame += `${pad(coloredBar, visibleBarLen)}\n`;
400
+ lineCount++;
401
+
402
+ this.linesDrawn = lineCount;
403
+ return frame;
404
+ }
405
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { COMMANDS } from './command-registry';
3
+ import type { CommandDef } from './command-registry';
4
+ import { generateRichZshCompletion } from './generate-zsh-completion';
5
+
6
+ describe('Zsh Completion Generator', () => {
7
+ const output = generateRichZshCompletion(COMMANDS);
8
+
9
+ test('produces valid compdef header', () => {
10
+ expect(output).toMatch(/^#compdef celilo/);
11
+ });
12
+
13
+ test('contains auto-generated notice', () => {
14
+ expect(output).toContain('Auto-generated by: celilo completion zsh');
15
+ expect(output).toContain('Do not edit manually');
16
+ });
17
+
18
+ test('contains main _celilo function', () => {
19
+ expect(output).toContain('_celilo()');
20
+ expect(output).toContain('_celilo_commands()');
21
+ });
22
+
23
+ test('contains all top-level commands in _celilo_commands', () => {
24
+ for (const cmd of COMMANDS) {
25
+ expect(output).toContain(`'${cmd.name}:`);
26
+ }
27
+ });
28
+
29
+ test('generates functions for all commands with subcommands', () => {
30
+ function checkSubcommands(commands: CommandDef[], path: string[]): void {
31
+ for (const cmd of commands) {
32
+ if (cmd.subcommands && cmd.subcommands.length > 0) {
33
+ const fnName = `_celilo${path.length > 0 ? `_${[...path, cmd.name].join('_')}` : `_${cmd.name}`}`;
34
+ expect(output).toContain(`${fnName}()`);
35
+ expect(output).toContain(`${fnName}_commands()`);
36
+
37
+ // Recurse
38
+ checkSubcommands(cmd.subcommands, [...path, cmd.name]);
39
+ }
40
+ }
41
+ }
42
+ checkSubcommands(COMMANDS, []);
43
+ });
44
+
45
+ test('generates dynamic completion functions for database-backed args', () => {
46
+ expect(output).toContain('_celilo_module_ids()');
47
+ expect(output).toContain('_celilo_service_ids()');
48
+ expect(output).toContain('_celilo_machine_hostnames()');
49
+ expect(output).toContain('_celilo_capability_names()');
50
+ expect(output).toContain('_celilo_config_keys()');
51
+ expect(output).toContain('_celilo_system_config_keys()');
52
+ expect(output).toContain('_celilo_system_secret_keys()');
53
+ });
54
+
55
+ test('dynamic completions query sqlite3', () => {
56
+ expect(output).toContain('sqlite3 "$db_path"');
57
+ expect(output).toContain('SELECT id FROM modules');
58
+ expect(output).toContain('SELECT service_id, name FROM container_services');
59
+ expect(output).toContain('SELECT hostname FROM machines');
60
+ expect(output).toContain('SELECT capability_name, module_id FROM capabilities');
61
+ });
62
+
63
+ test('includes flag completions for commands with flags', () => {
64
+ // Module import has --target and --auto-generate-secrets
65
+ expect(output).toContain('--target');
66
+ expect(output).toContain('--auto-generate-secrets');
67
+
68
+ // Machine add has --ip, --ssh-user, etc.
69
+ expect(output).toContain('--ip');
70
+ expect(output).toContain('--ssh-user');
71
+ expect(output).toContain('--ssh-key-file');
72
+
73
+ // Service remove has --force
74
+ expect(output).toContain('--force');
75
+ });
76
+
77
+ test('ends with _celilo invocation', () => {
78
+ expect(output.trimEnd()).toMatch(/_celilo "\$@"$/);
79
+ });
80
+
81
+ test('all subcommand descriptions are present', () => {
82
+ function checkDescriptions(commands: CommandDef[]): void {
83
+ for (const cmd of commands) {
84
+ if (cmd.subcommands) {
85
+ for (const sub of cmd.subcommands) {
86
+ // Description should appear in the _commands function
87
+ expect(output).toContain(sub.description.replace(/:/g, '\\:'));
88
+ }
89
+ checkDescriptions(cmd.subcommands);
90
+ }
91
+ }
92
+ }
93
+ checkDescriptions(COMMANDS);
94
+ });
95
+ });