@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.
- package/README.md +1566 -0
- package/bin/celilo +16 -0
- package/drizzle/0000_complex_puma.sql +179 -0
- package/drizzle/0001_dizzy_wolfpack.sql +2 -0
- package/drizzle/0002_web_routes.sql +16 -0
- package/drizzle/0003_backup_storage.sql +32 -0
- package/drizzle/meta/0000_snapshot.json +1151 -0
- package/drizzle/meta/0001_snapshot.json +1167 -0
- package/drizzle/meta/0002_snapshot.json +1257 -0
- package/drizzle/meta/_journal.json +27 -0
- package/package.json +64 -0
- package/schemas/system_config.json +106 -0
- package/src/__integration__/container-services-cli.integration.test.ts +246 -0
- package/src/ansible/dependencies.test.ts +309 -0
- package/src/ansible/dependencies.ts +896 -0
- package/src/ansible/inventory.test.ts +463 -0
- package/src/ansible/inventory.ts +445 -0
- package/src/ansible/secrets.ts +222 -0
- package/src/ansible/validation.test.ts +92 -0
- package/src/ansible/validation.ts +272 -0
- package/src/api-clients/digitalocean.ts +94 -0
- package/src/api-clients/proxmox.ts +655 -0
- package/src/capabilities/logging-wrapper.test.ts +217 -0
- package/src/capabilities/lookup.test.ts +149 -0
- package/src/capabilities/lookup.ts +89 -0
- package/src/capabilities/public-web-helpers.test.ts +198 -0
- package/src/capabilities/public-web-publish.test.ts +458 -0
- package/src/capabilities/registration.test.ts +395 -0
- package/src/capabilities/registration.ts +200 -0
- package/src/capabilities/route-validation.test.ts +121 -0
- package/src/capabilities/route-validation.ts +96 -0
- package/src/capabilities/secret-ref.test.ts +313 -0
- package/src/capabilities/secret-validation.ts +157 -0
- package/src/capabilities/secrets.test.ts +750 -0
- package/src/capabilities/secrets.ts +244 -0
- package/src/capabilities/validation.test.ts +613 -0
- package/src/capabilities/validation.ts +160 -0
- package/src/capabilities/well-known.test.ts +238 -0
- package/src/capabilities/well-known.ts +222 -0
- package/src/cli/cli.test.ts +654 -0
- package/src/cli/command-registry.ts +742 -0
- package/src/cli/command-tree-parser.test.ts +180 -0
- package/src/cli/command-tree-parser.ts +193 -0
- package/src/cli/commands/backup-create.ts +137 -0
- package/src/cli/commands/backup-delete.ts +74 -0
- package/src/cli/commands/backup-import.ts +97 -0
- package/src/cli/commands/backup-list.ts +132 -0
- package/src/cli/commands/backup-name.ts +73 -0
- package/src/cli/commands/backup-prune.ts +98 -0
- package/src/cli/commands/backup-restore.ts +122 -0
- package/src/cli/commands/capability-info.ts +121 -0
- package/src/cli/commands/capability-list.ts +47 -0
- package/src/cli/commands/completion.ts +87 -0
- package/src/cli/commands/hook-run.ts +176 -0
- package/src/cli/commands/ipam.ts +607 -0
- package/src/cli/commands/machine-add.ts +235 -0
- package/src/cli/commands/machine-earmark.ts +82 -0
- package/src/cli/commands/machine-list.ts +77 -0
- package/src/cli/commands/machine-remove.ts +90 -0
- package/src/cli/commands/machine-status.ts +131 -0
- package/src/cli/commands/module-audit.ts +51 -0
- package/src/cli/commands/module-build.ts +60 -0
- package/src/cli/commands/module-config.ts +170 -0
- package/src/cli/commands/module-deploy.ts +71 -0
- package/src/cli/commands/module-generate.ts +236 -0
- package/src/cli/commands/module-health.ts +108 -0
- package/src/cli/commands/module-import.ts +80 -0
- package/src/cli/commands/module-list.ts +43 -0
- package/src/cli/commands/module-logs.ts +73 -0
- package/src/cli/commands/module-remove.ts +162 -0
- package/src/cli/commands/module-show.ts +208 -0
- package/src/cli/commands/module-status.ts +131 -0
- package/src/cli/commands/module-types.ts +189 -0
- package/src/cli/commands/module-upgrade.ts +192 -0
- package/src/cli/commands/package.ts +68 -0
- package/src/cli/commands/secret-list.ts +99 -0
- package/src/cli/commands/secret-set.ts +134 -0
- package/src/cli/commands/service-add-digitalocean.ts +133 -0
- package/src/cli/commands/service-add-proxmox.ts +342 -0
- package/src/cli/commands/service-config-get.ts +83 -0
- package/src/cli/commands/service-config-set.ts +145 -0
- package/src/cli/commands/service-list.ts +74 -0
- package/src/cli/commands/service-reconfigure.ts +230 -0
- package/src/cli/commands/service-remove.ts +103 -0
- package/src/cli/commands/service-verify.ts +240 -0
- package/src/cli/commands/status.ts +216 -0
- package/src/cli/commands/storage-add-local.ts +106 -0
- package/src/cli/commands/storage-add-s3.ts +114 -0
- package/src/cli/commands/storage-list.ts +72 -0
- package/src/cli/commands/storage-remove.ts +54 -0
- package/src/cli/commands/storage-set-default.ts +44 -0
- package/src/cli/commands/storage-verify.ts +54 -0
- package/src/cli/commands/system-config.ts +168 -0
- package/src/cli/commands/system-init.ts +314 -0
- package/src/cli/commands/system-secret-get.ts +98 -0
- package/src/cli/commands/system-secret-set.ts +76 -0
- package/src/cli/commands/system-vault-password.ts +34 -0
- package/src/cli/completion.test.ts +37 -0
- package/src/cli/completion.ts +482 -0
- package/src/cli/fuel-gauge.test.ts +208 -0
- package/src/cli/fuel-gauge.ts +405 -0
- package/src/cli/generate-zsh-completion.test.ts +95 -0
- package/src/cli/generate-zsh-completion.ts +497 -0
- package/src/cli/index.ts +1583 -0
- package/src/cli/interactive-config.test.ts +201 -0
- package/src/cli/interactive-config.ts +62 -0
- package/src/cli/parser.test.ts +227 -0
- package/src/cli/parser.ts +244 -0
- package/src/cli/prompts.test.ts +33 -0
- package/src/cli/prompts.ts +121 -0
- package/src/cli/types.ts +38 -0
- package/src/cli/validators.test.ts +235 -0
- package/src/cli/validators.ts +188 -0
- package/src/config/env.ts +41 -0
- package/src/config/paths.test.ts +172 -0
- package/src/config/paths.ts +108 -0
- package/src/db/client.ts +190 -0
- package/src/db/migrate.ts +30 -0
- package/src/db/schema.test.ts +221 -0
- package/src/db/schema.ts +434 -0
- package/src/hooks/capability-loader-firewall.test.ts +246 -0
- package/src/hooks/capability-loader.test.ts +100 -0
- package/src/hooks/capability-loader.ts +520 -0
- package/src/hooks/define-hook.test.ts +488 -0
- package/src/hooks/executor.test.ts +462 -0
- package/src/hooks/executor.ts +469 -0
- package/src/hooks/logger.test.ts +54 -0
- package/src/hooks/logger.ts +95 -0
- package/src/hooks/test-fixtures/failing-hook.ts +13 -0
- package/src/hooks/test-fixtures/no-default-hook.ts +6 -0
- package/src/hooks/test-fixtures/success-hook.ts +20 -0
- package/src/hooks/test-fixtures/unbranded-hook.ts +11 -0
- package/src/hooks/test-fixtures/void-hook.ts +13 -0
- package/src/hooks/types.ts +89 -0
- package/src/infrastructure/property-extractor.test.ts +194 -0
- package/src/infrastructure/property-extractor.ts +151 -0
- package/src/ipam/allocator.test.ts +442 -0
- package/src/ipam/allocator.ts +369 -0
- package/src/ipam/auto-allocator.test.ts +247 -0
- package/src/ipam/auto-allocator.ts +270 -0
- package/src/ipam/subnet-parser.test.ts +107 -0
- package/src/ipam/subnet-parser.ts +136 -0
- package/src/manifest/contracts/index.ts +61 -0
- package/src/manifest/contracts/v1.ts +118 -0
- package/src/manifest/json-schema-roundtrip.test.ts +99 -0
- package/src/manifest/schema.ts +367 -0
- package/src/manifest/template-validator.test.ts +231 -0
- package/src/manifest/template-validator.ts +322 -0
- package/src/manifest/validate.test.ts +1180 -0
- package/src/manifest/validate.ts +415 -0
- package/src/module/import.test.ts +355 -0
- package/src/module/import.ts +676 -0
- package/src/module/packaging/audit.ts +169 -0
- package/src/module/packaging/build.ts +228 -0
- package/src/module/packaging/checksum.ts +41 -0
- package/src/module/packaging/extract.ts +234 -0
- package/src/module/packaging/signature.ts +47 -0
- package/src/secrets/encryption.test.ts +284 -0
- package/src/secrets/encryption.ts +162 -0
- package/src/secrets/generators.test.ts +112 -0
- package/src/secrets/generators.ts +127 -0
- package/src/secrets/master-key.test.ts +159 -0
- package/src/secrets/master-key.ts +114 -0
- package/src/secrets/storage.test.ts +115 -0
- package/src/secrets/storage.ts +106 -0
- package/src/secrets/vault.test.ts +35 -0
- package/src/secrets/vault.ts +42 -0
- package/src/services/backup-create.ts +532 -0
- package/src/services/backup-metadata.ts +198 -0
- package/src/services/backup-restore.ts +229 -0
- package/src/services/backup-retention.ts +84 -0
- package/src/services/backup-storage.ts +281 -0
- package/src/services/build-stream.test.ts +122 -0
- package/src/services/build-stream.ts +201 -0
- package/src/services/config-interview.ts +694 -0
- package/src/services/container-service.test.ts +298 -0
- package/src/services/container-service.ts +401 -0
- package/src/services/cross-module-data-manager.test.ts +405 -0
- package/src/services/cross-module-data-manager.ts +412 -0
- package/src/services/deploy-ansible.ts +88 -0
- package/src/services/deploy-planner.ts +153 -0
- package/src/services/deploy-preflight.ts +274 -0
- package/src/services/deploy-ssh.ts +131 -0
- package/src/services/deploy-terraform.test.ts +55 -0
- package/src/services/deploy-terraform.ts +445 -0
- package/src/services/deploy-validation.ts +311 -0
- package/src/services/dns-auto-register.ts +211 -0
- package/src/services/health-runner.ts +184 -0
- package/src/services/infrastructure-selector.test.ts +485 -0
- package/src/services/infrastructure-selector.ts +245 -0
- package/src/services/infrastructure-variable-resolver.test.ts +751 -0
- package/src/services/infrastructure-variable-resolver.ts +234 -0
- package/src/services/machine-detector.ts +328 -0
- package/src/services/machine-pool.test.ts +405 -0
- package/src/services/machine-pool.ts +316 -0
- package/src/services/manifest-validation.ts +120 -0
- package/src/services/module-build.test.ts +290 -0
- package/src/services/module-build.ts +431 -0
- package/src/services/module-config.test.ts +237 -0
- package/src/services/module-config.ts +298 -0
- package/src/services/module-deploy.ts +862 -0
- package/src/services/module-types-drift.test.ts +73 -0
- package/src/services/module-types-generator.test.ts +288 -0
- package/src/services/module-types-generator.ts +189 -0
- package/src/services/proxmox-state-recovery.ts +140 -0
- package/src/services/schema-validation.ts +155 -0
- package/src/services/secret-schema-loader.test.ts +311 -0
- package/src/services/secret-schema-loader.ts +239 -0
- package/src/services/ssh-key-manager.test.ts +283 -0
- package/src/services/ssh-key-manager.ts +193 -0
- package/src/services/storage-providers/local.ts +105 -0
- package/src/services/storage-providers/s3.ts +182 -0
- package/src/services/storage-providers/types.ts +24 -0
- package/src/services/system-config-schema-types.ts +25 -0
- package/src/services/system-config-validator.test.ts +160 -0
- package/src/services/system-config-validator.ts +74 -0
- package/src/services/system-init.test.ts +153 -0
- package/src/services/system-init.ts +253 -0
- package/src/services/terraform-safety.ts +174 -0
- package/src/services/zone-detector.test.ts +110 -0
- package/src/services/zone-detector.ts +102 -0
- package/src/services/zone-policy.test.ts +97 -0
- package/src/services/zone-policy.ts +126 -0
- package/src/templates/generator.test.ts +645 -0
- package/src/templates/generator.ts +1119 -0
- package/src/templates/types.ts +62 -0
- package/src/test-utils/INTERACTIVE_PROMPTS.md +167 -0
- package/src/test-utils/cli-context-interactive.test.ts +152 -0
- package/src/test-utils/cli-context-server.test.ts +66 -0
- package/src/test-utils/cli-context.test.ts +273 -0
- package/src/test-utils/cli-context.ts +677 -0
- package/src/test-utils/cli-result.test.ts +282 -0
- package/src/test-utils/cli-result.ts +241 -0
- package/src/test-utils/cli.ts +55 -0
- package/src/test-utils/completion-harness.test.ts +126 -0
- package/src/test-utils/completion-harness.ts +82 -0
- package/src/test-utils/database.test.ts +182 -0
- package/src/test-utils/database.ts +126 -0
- package/src/test-utils/filesystem.test.ts +208 -0
- package/src/test-utils/filesystem.ts +142 -0
- package/src/test-utils/fixtures.test.ts +123 -0
- package/src/test-utils/fixtures.ts +160 -0
- package/src/test-utils/golden-diff.ts +197 -0
- package/src/test-utils/index.ts +77 -0
- package/src/test-utils/integration.ts +81 -0
- package/src/test-utils/module-fixtures.ts +468 -0
- package/src/test-utils/modules.test.ts +144 -0
- package/src/test-utils/modules.ts +183 -0
- package/src/test-utils/setup-test-db.ts +90 -0
- package/src/test-utils/value-extractor.test.ts +231 -0
- package/src/test-utils/value-extractor.ts +228 -0
- package/src/types/infrastructure.ts +157 -0
- package/src/utils/shell.test.ts +365 -0
- package/src/utils/shell.ts +159 -0
- package/src/validation/schemas.ts +166 -0
- package/src/variables/ansible-resolver.test.ts +142 -0
- package/src/variables/ansible-resolver.ts +69 -0
- package/src/variables/capability-self-ref.test.ts +220 -0
- package/src/variables/context.test.ts +1265 -0
- package/src/variables/context.ts +624 -0
- package/src/variables/declarative-derivation.test.ts +743 -0
- package/src/variables/declarative-derivation.ts +200 -0
- package/src/variables/parser.test.ts +231 -0
- package/src/variables/parser.ts +76 -0
- package/src/variables/resolver.test.ts +458 -0
- package/src/variables/resolver.ts +282 -0
- 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
|
+
}
|