@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,677 @@
1
+ /**
2
+ * CLIContext - Persistent Process Management for CLI Testing
3
+ *
4
+ * Maintains a single long-lived CLI process per test that accepts multiple commands.
5
+ * Solves resource leak problems from spawning fresh processes per command.
6
+ *
7
+ * Inspired by nixt (interactive API, filesystem helpers, middleware hooks) and
8
+ * execa (rich errors, verbose mode), but with persistent process architecture.
9
+ */
10
+
11
+ import { type ChildProcess, spawn } from 'node:child_process';
12
+ import { EventEmitter } from 'node:events';
13
+ import { mkdir, rm, stat, writeFile as writeFileFs } from 'node:fs/promises';
14
+ import type { CLIResult } from './cli-result';
15
+ import { CLIResultImpl } from './cli-result';
16
+
17
+ /**
18
+ * Command execution options
19
+ */
20
+ export interface RunOptions {
21
+ /** Timeout in milliseconds (default: 30000) */
22
+ timeout?: number;
23
+ }
24
+
25
+ /**
26
+ * Response builder for interactive prompts
27
+ * Provides fluent API: cli.on(pattern).respond(text)
28
+ */
29
+ export class ResponseBuilder {
30
+ constructor(
31
+ private cli: CLIContext,
32
+ private pattern: string | RegExp,
33
+ ) {}
34
+
35
+ /**
36
+ * Respond to the prompt with given text
37
+ * Waits for pattern to appear, then sends response
38
+ *
39
+ * Now enabled with persistent process!
40
+ */
41
+ async respond(text: string, options: { timeout?: number } = {}): Promise<void> {
42
+ await this.cli.expectOutput(this.pattern, options);
43
+ if (text) {
44
+ await this.cli.sendKeys(text);
45
+ }
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Trace entry for debugging
51
+ */
52
+ interface TraceEntry {
53
+ type: 'command' | 'output' | 'error' | 'input';
54
+ timestamp: number;
55
+ data: unknown;
56
+ }
57
+
58
+ /**
59
+ * Command response from CLI server
60
+ */
61
+ interface CommandResponse {
62
+ id: number;
63
+ success: boolean;
64
+ message?: string;
65
+ error?: string;
66
+ details?: string;
67
+ exitCode: number;
68
+ duration: number;
69
+ }
70
+
71
+ /**
72
+ * CLIContext - Persistent CLI process manager
73
+ *
74
+ * Uses true persistent process with CLI server mode
75
+ *
76
+ * @example
77
+ * ```typescript
78
+ * const cli = await CLIContext.create();
79
+ *
80
+ * // Run commands (reuses same process)
81
+ * await cli.run('module add homebridge').expectSuccess();
82
+ * await cli.run('module list');
83
+ *
84
+ * // Interactive prompts (now enabled!)
85
+ * await cli.on(/Enter hostname:/).respond('iot\n');
86
+ *
87
+ * // Filesystem helpers (nixt-inspired)
88
+ * await cli.mkdir('/tmp/test');
89
+ * await cli.writeFile('/tmp/config.json', '{}');
90
+ *
91
+ * // Cleanup
92
+ * await cli.dispose();
93
+ * ```
94
+ */
95
+ export class CLIContext {
96
+ private process: ChildProcess | null = null;
97
+ private outputBuffer = '';
98
+ private errorBuffer = '';
99
+ private trace: TraceEntry[] = [];
100
+ private verbose = false;
101
+ private commandId = 0;
102
+ private pendingResponses = new Map<
103
+ number,
104
+ {
105
+ resolve: (response: CommandResponse) => void;
106
+ reject: (error: Error) => void;
107
+ }
108
+ >();
109
+ private hooks: {
110
+ beforeEach: Array<() => Promise<void>>;
111
+ afterEach: Array<() => Promise<void>>;
112
+ } = {
113
+ beforeEach: [],
114
+ afterEach: [],
115
+ };
116
+ private static customMethods: Record<string, (...args: unknown[]) => unknown> = {};
117
+ private events = new EventEmitter();
118
+
119
+ private constructor(
120
+ private cliPath: string,
121
+ private env: Record<string, string>,
122
+ ) {}
123
+
124
+ /**
125
+ * Create new CLI context with persistent process
126
+ *
127
+ * @param cliPath - Path to CLI entry point (default: src/cli/index.ts)
128
+ * @param env - Environment variables for CLI process
129
+ * @returns New CLIContext instance
130
+ */
131
+ static async create(
132
+ cliPath = 'src/cli/index.ts',
133
+ env: Record<string, string> = {},
134
+ ): Promise<CLIContext> {
135
+ const context = new CLIContext(cliPath, env);
136
+ await context.startProcess();
137
+ return context;
138
+ }
139
+
140
+ /**
141
+ * Start the persistent CLI process
142
+ *
143
+ * Starts true persistent process with CLI server mode.
144
+ * Process stays alive and accepts commands via stdin/stdout protocol.
145
+ */
146
+ private async startProcess(): Promise<void> {
147
+ // Set up ready promise BEFORE spawning to avoid race condition
148
+ let readyResolve: () => void;
149
+ let readyReject: (error: Error) => void;
150
+
151
+ const readyPromise = new Promise<void>((resolve, reject) => {
152
+ readyResolve = resolve;
153
+ readyReject = reject;
154
+ });
155
+
156
+ const timeoutDuration = 10000;
157
+ const timeout = setTimeout(() => {
158
+ readyReject(
159
+ new Error(
160
+ `CLI process failed to start within ${timeoutDuration / 1000} seconds.\n` +
161
+ `Check that CLI server mode is working: CLI_SERVER_MODE=true bun run ${this.cliPath}`,
162
+ ),
163
+ );
164
+ }, timeoutDuration);
165
+
166
+ // Set up event listeners before spawn
167
+ const onReady = () => {
168
+ clearTimeout(timeout);
169
+ readyResolve();
170
+ };
171
+
172
+ const onSpawnError = (error: Error) => {
173
+ clearTimeout(timeout);
174
+ this.events.off('ready', onReady);
175
+ this.events.off('spawn-exit', onSpawnExit);
176
+ readyReject(error);
177
+ };
178
+
179
+ const onSpawnExit = (code: number) => {
180
+ clearTimeout(timeout);
181
+ this.events.off('ready', onReady);
182
+ this.events.off('spawn-error', onSpawnError);
183
+ readyReject(new Error(`CLI process exited prematurely with code ${code}`));
184
+ };
185
+
186
+ this.events.on('ready', onReady);
187
+ this.events.on('spawn-error', onSpawnError);
188
+ this.events.on('spawn-exit', onSpawnExit);
189
+
190
+ this.process = spawn('bun', ['run', this.cliPath], {
191
+ env: {
192
+ ...process.env,
193
+ ...this.env,
194
+ CLI_SERVER_MODE: 'true', // Enable server mode
195
+ },
196
+ stdio: ['pipe', 'pipe', 'pipe'],
197
+ });
198
+
199
+ if (!this.process.stdout || !this.process.stderr || !this.process.stdin) {
200
+ throw new Error('Failed to create process streams');
201
+ }
202
+
203
+ // Handle stdout - parse JSON responses
204
+ let stdoutBuffer = '';
205
+ this.process.stdout.on('data', (data: Buffer) => {
206
+ const text = data.toString();
207
+ stdoutBuffer += text;
208
+
209
+ // Capture all output for expectOutput() to search
210
+ this.outputBuffer += text;
211
+
212
+ // Process complete JSON lines
213
+ const lines = stdoutBuffer.split('\n');
214
+ stdoutBuffer = lines.pop() || ''; // Keep incomplete line in buffer
215
+
216
+ for (const line of lines) {
217
+ const trimmed = line.trim();
218
+ if (!trimmed) continue;
219
+
220
+ // Only process JSON lines (ignore non-JSON output like "Database initialized...")
221
+ if (!trimmed.startsWith('{')) {
222
+ if (this.verbose) {
223
+ console.log('[CLIContext] Non-JSON output:', trimmed);
224
+ }
225
+ continue;
226
+ }
227
+
228
+ try {
229
+ const response = JSON.parse(trimmed);
230
+
231
+ // Ready signal
232
+ if (response.type === 'ready') {
233
+ if (this.verbose) {
234
+ console.log('[CLIContext] Process ready, PID:', response.pid);
235
+ }
236
+ this.events.emit('ready');
237
+ continue;
238
+ }
239
+
240
+ // Command response
241
+ if (typeof response.id === 'number') {
242
+ const pending = this.pendingResponses.get(response.id);
243
+ if (pending) {
244
+ this.pendingResponses.delete(response.id);
245
+ pending.resolve(response as CommandResponse);
246
+ }
247
+ }
248
+ } catch (error) {
249
+ if (this.verbose) {
250
+ console.error('[CLIContext] Failed to parse JSON:', trimmed, error);
251
+ }
252
+ }
253
+ }
254
+ });
255
+
256
+ // Handle stderr - log errors
257
+ this.process.stderr.on('data', (data: Buffer) => {
258
+ const text = data.toString();
259
+ this.errorBuffer += text;
260
+ if (this.verbose) {
261
+ console.error('[CLI stderr]', text);
262
+ }
263
+ this.trace.push({
264
+ type: 'error',
265
+ timestamp: Date.now(),
266
+ data: text,
267
+ });
268
+ });
269
+
270
+ // Handle process exit
271
+ this.process.on('exit', (code: number | null, signal: string | null) => {
272
+ if (this.verbose) {
273
+ console.log('[CLIContext] Process exited, code:', code, 'signal:', signal);
274
+ }
275
+ this.trace.push({
276
+ type: 'command',
277
+ timestamp: Date.now(),
278
+ data: { event: 'process_exit', code, signal },
279
+ });
280
+
281
+ // Reject all pending commands
282
+ for (const [_id, pending] of this.pendingResponses.entries()) {
283
+ pending.reject(new Error(`CLI process exited (code: ${code}, signal: ${signal})`));
284
+ }
285
+ this.pendingResponses.clear();
286
+
287
+ // Emit events for readyPromise to catch
288
+ this.events.emit('spawn-exit', code);
289
+ });
290
+
291
+ // Handle process errors
292
+ this.process.on('error', (error) => {
293
+ if (this.verbose) {
294
+ console.error('[CLIContext] Process error:', error);
295
+ }
296
+ this.events.emit('spawn-error', error);
297
+ });
298
+
299
+ // Wait for ready signal
300
+ await readyPromise;
301
+
302
+ if (this.verbose) {
303
+ console.log('[CLIContext] Persistent process started successfully');
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Run a command (uses persistent process)
309
+ *
310
+ * @param command - Command to execute
311
+ * @param options - Execution options
312
+ * @returns CLIResult with output and assertions
313
+ */
314
+ async run(command: string, options: RunOptions = {}): Promise<CLIResult> {
315
+ if (!this.process || !this.process.stdin) {
316
+ throw new Error('CLI process not started');
317
+ }
318
+
319
+ // Run before hooks
320
+ for (const hook of this.hooks.beforeEach) {
321
+ await hook();
322
+ }
323
+
324
+ const startTime = Date.now();
325
+ const id = ++this.commandId;
326
+
327
+ this.trace.push({
328
+ type: 'command',
329
+ timestamp: startTime,
330
+ data: { command, id },
331
+ });
332
+
333
+ if (this.verbose) {
334
+ console.log(`[CLI command #${id}] ${command}`);
335
+ }
336
+
337
+ // Send command to server
338
+ const request = JSON.stringify({ command, id });
339
+ this.process.stdin.write(`${request}\n`);
340
+
341
+ this.trace.push({
342
+ type: 'input',
343
+ timestamp: Date.now(),
344
+ data: request,
345
+ });
346
+
347
+ // Wait for response
348
+ const timeout = options.timeout ?? 30000;
349
+ const response = await Promise.race([
350
+ // Response promise
351
+ new Promise<CommandResponse>((resolve, reject) => {
352
+ this.pendingResponses.set(id, { resolve, reject });
353
+ }),
354
+ // Timeout promise
355
+ new Promise<CommandResponse>((_, reject) =>
356
+ setTimeout(() => reject(new Error(`Command timed out after ${timeout}ms`)), timeout),
357
+ ),
358
+ ]);
359
+
360
+ // Clean up if timeout raced
361
+ this.pendingResponses.delete(id);
362
+
363
+ const duration = Date.now() - startTime;
364
+
365
+ if (this.verbose) {
366
+ console.log(`[CLI response #${id}] exitCode=${response.exitCode}, duration=${duration}ms`);
367
+ }
368
+
369
+ // Create result
370
+ const result = new CLIResultImpl(
371
+ command,
372
+ response.message || '',
373
+ response.error || '',
374
+ response.exitCode,
375
+ duration,
376
+ false, // Server mode doesn't timeout, we handle it at request level
377
+ );
378
+
379
+ // Run after hooks
380
+ for (const hook of this.hooks.afterEach) {
381
+ await hook();
382
+ }
383
+
384
+ return result;
385
+ }
386
+
387
+ /**
388
+ * Run command expecting failure
389
+ * Syntactic sugar for run() with expectFailure()
390
+ */
391
+ async runExpectingFailure(command: string, options: RunOptions = {}): Promise<CLIResult> {
392
+ const result = await this.run(command, options);
393
+ return result.expectFailure();
394
+ }
395
+
396
+ /**
397
+ * Wait for output pattern to appear (nixt-inspired)
398
+ * Returns ResponseBuilder for fluent API
399
+ */
400
+ on(pattern: string | RegExp): ResponseBuilder {
401
+ return new ResponseBuilder(this, pattern);
402
+ }
403
+
404
+ /**
405
+ * Wait for output pattern to appear
406
+ *
407
+ * Now enabled with persistent process!
408
+ * Monitors outputBuffer for pattern match.
409
+ *
410
+ * @param pattern - String or regex to match in output
411
+ * @param options - Options with timeout
412
+ */
413
+ async expectOutput(pattern: string | RegExp, options: { timeout?: number } = {}): Promise<void> {
414
+ const timeout = options.timeout ?? 5000;
415
+ const startTime = Date.now();
416
+
417
+ return new Promise((resolve, reject) => {
418
+ const checkOutput = () => {
419
+ const matches =
420
+ typeof pattern === 'string'
421
+ ? this.outputBuffer.includes(pattern)
422
+ : pattern.test(this.outputBuffer);
423
+
424
+ if (matches) {
425
+ resolve();
426
+ return;
427
+ }
428
+
429
+ if (Date.now() - startTime > timeout) {
430
+ reject(
431
+ new Error(`Timeout waiting for pattern: ${pattern}\nOutput: ${this.outputBuffer}`),
432
+ );
433
+ return;
434
+ }
435
+
436
+ setTimeout(checkOutput, 50);
437
+ };
438
+
439
+ checkOutput();
440
+ });
441
+ }
442
+
443
+ /**
444
+ * Send keys to stdin
445
+ *
446
+ * Now enabled with persistent process!
447
+ * Sends input directly to the CLI process stdin.
448
+ */
449
+ async sendKeys(input: string): Promise<void> {
450
+ if (!this.process || !this.process.stdin) {
451
+ throw new Error('CLI process not started');
452
+ }
453
+
454
+ this.process.stdin.write(input);
455
+ this.trace.push({
456
+ type: 'input',
457
+ timestamp: Date.now(),
458
+ data: input,
459
+ });
460
+
461
+ if (this.verbose) {
462
+ console.log(`[CLI input] ${input}`);
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Register middleware hook to run before each command (nixt-inspired)
468
+ */
469
+ beforeEach(fn: () => Promise<void>): this {
470
+ this.hooks.beforeEach.push(fn);
471
+ return this;
472
+ }
473
+
474
+ /**
475
+ * Register middleware hook to run after each command (nixt-inspired)
476
+ */
477
+ afterEach(fn: () => Promise<void>): this {
478
+ this.hooks.afterEach.push(fn);
479
+ return this;
480
+ }
481
+
482
+ /**
483
+ * Create directory (nixt-inspired filesystem helper)
484
+ */
485
+ async mkdir(path: string): Promise<void> {
486
+ await mkdir(path, { recursive: true });
487
+ this.trace.push({
488
+ type: 'command',
489
+ timestamp: Date.now(),
490
+ data: { filesystem: 'mkdir', path },
491
+ });
492
+ }
493
+
494
+ /**
495
+ * Write file (nixt-inspired filesystem helper)
496
+ */
497
+ async writeFile(path: string, content: string): Promise<void> {
498
+ await writeFileFs(path, content, 'utf-8');
499
+ this.trace.push({
500
+ type: 'command',
501
+ timestamp: Date.now(),
502
+ data: { filesystem: 'writeFile', path, size: content.length },
503
+ });
504
+ }
505
+
506
+ /**
507
+ * Remove directory (nixt-inspired filesystem helper)
508
+ */
509
+ async rmdir(path: string): Promise<void> {
510
+ await rm(path, { recursive: true, force: true });
511
+ this.trace.push({
512
+ type: 'command',
513
+ timestamp: Date.now(),
514
+ data: { filesystem: 'rmdir', path },
515
+ });
516
+ }
517
+
518
+ /**
519
+ * Remove file (nixt-inspired filesystem helper)
520
+ */
521
+ async unlink(path: string): Promise<void> {
522
+ await rm(path, { force: true });
523
+ this.trace.push({
524
+ type: 'command',
525
+ timestamp: Date.now(),
526
+ data: { filesystem: 'unlink', path },
527
+ });
528
+ }
529
+
530
+ /**
531
+ * Check if path exists (nixt-inspired filesystem helper)
532
+ */
533
+ async exists(path: string): Promise<boolean> {
534
+ try {
535
+ await stat(path);
536
+ return true;
537
+ } catch {
538
+ return false;
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Enable/disable verbose mode (execa-inspired)
544
+ * When enabled, logs all I/O to console
545
+ */
546
+ setVerbose(enabled: boolean): this {
547
+ this.verbose = enabled;
548
+ return this;
549
+ }
550
+
551
+ /**
552
+ * Clone context with same configuration (nixt-inspired)
553
+ * Creates new context with same env and hooks
554
+ *
555
+ * Starts a new persistent process for the cloned context
556
+ */
557
+ async clone(): Promise<CLIContext> {
558
+ const cloned = new CLIContext(this.cliPath, { ...this.env });
559
+ cloned.hooks = {
560
+ beforeEach: [...this.hooks.beforeEach],
561
+ afterEach: [...this.hooks.afterEach],
562
+ };
563
+ cloned.verbose = this.verbose;
564
+ await cloned.startProcess();
565
+ return cloned;
566
+ }
567
+
568
+ /**
569
+ * Export execution trace for debugging
570
+ *
571
+ * @param format - Output format (json, html, markdown)
572
+ * @returns Formatted trace string
573
+ */
574
+ exportTrace(format: 'json' | 'html' | 'markdown'): string {
575
+ switch (format) {
576
+ case 'json':
577
+ return JSON.stringify(this.trace, null, 2);
578
+ case 'html':
579
+ return this.traceToHtml();
580
+ case 'markdown':
581
+ return this.traceToMarkdown();
582
+ default:
583
+ throw new Error(`Unknown format: ${format}`);
584
+ }
585
+ }
586
+
587
+ private traceToHtml(): string {
588
+ // TODO: Implement HTML trace viewer
589
+ return `<html><body><pre>${JSON.stringify(this.trace, null, 2)}</pre></body></html>`;
590
+ }
591
+
592
+ private traceToMarkdown(): string {
593
+ let md = '# CLI Execution Trace\n\n';
594
+ for (const entry of this.trace) {
595
+ const time = new Date(entry.timestamp).toISOString();
596
+ md += `## ${time} - ${entry.type}\n\n`;
597
+ md += '```\n';
598
+ md += typeof entry.data === 'string' ? entry.data : JSON.stringify(entry.data, null, 2);
599
+ md += '\n```\n\n';
600
+ }
601
+ return md;
602
+ }
603
+
604
+ /**
605
+ * Cleanup and terminate process
606
+ * Sends exit command to server and waits for graceful shutdown
607
+ */
608
+ async dispose(): Promise<void> {
609
+ if (this.verbose) {
610
+ console.log('[CLIContext] Disposing...');
611
+ }
612
+
613
+ if (this.process?.stdin) {
614
+ try {
615
+ // Send exit command
616
+ const id = ++this.commandId;
617
+ const request = JSON.stringify({ command: '__exit__', id });
618
+ this.process.stdin.write(`${request}\n`);
619
+
620
+ // Wait for exit (with timeout)
621
+ await Promise.race([
622
+ new Promise<void>((resolve) => {
623
+ this.process?.once('exit', () => resolve());
624
+ }),
625
+ new Promise<void>((resolve) => setTimeout(resolve, 1000)),
626
+ ]);
627
+ } catch (error) {
628
+ // Ignore errors during shutdown
629
+ if (this.verbose) {
630
+ console.warn('[CLIContext] Error during dispose:', error);
631
+ }
632
+ }
633
+
634
+ // Force kill if still alive
635
+ if (this.process && !this.process.killed) {
636
+ this.process.kill();
637
+ }
638
+ }
639
+
640
+ this.process = null;
641
+
642
+ if (this.verbose) {
643
+ console.log('[CLIContext] Disposed');
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Register custom method (nixt-inspired module system)
649
+ *
650
+ * @example
651
+ * ```typescript
652
+ * CLIContext.register('expectModuleInstalled', function(id: string) {
653
+ * return this.run('module list').expectStdout(new RegExp(id));
654
+ * });
655
+ *
656
+ * await cli.expectModuleInstalled('homebridge');
657
+ * ```
658
+ */
659
+ static register(name: string, fn: (...args: unknown[]) => unknown): void;
660
+ static register(modules: Record<string, (...args: unknown[]) => unknown>): void;
661
+ static register(
662
+ nameOrModules: string | Record<string, (...args: unknown[]) => unknown>,
663
+ fn?: (...args: unknown[]) => unknown,
664
+ ): void {
665
+ if (typeof nameOrModules === 'string' && fn) {
666
+ CLIContext.customMethods[nameOrModules] = fn;
667
+ // Add to prototype
668
+ (CLIContext.prototype as unknown as Record<string, (...args: unknown[]) => unknown>)[
669
+ nameOrModules
670
+ ] = fn;
671
+ } else if (typeof nameOrModules === 'object') {
672
+ for (const [name, func] of Object.entries(nameOrModules)) {
673
+ CLIContext.register(name, func);
674
+ }
675
+ }
676
+ }
677
+ }