@adcp/client 2.3.2 → 2.4.1

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 CHANGED
@@ -455,6 +455,31 @@ WHERE operation_id = 'delivery_report_agent_x_2025-10'
455
455
  ORDER BY sequence_number;
456
456
  ```
457
457
 
458
+ ## CLI Tool
459
+
460
+ For development and testing, use the included CLI tool to interact with AdCP agents:
461
+
462
+ ```bash
463
+ # List available tools from an agent
464
+ npx @adcp/client mcp https://test-agent.adcontextprotocol.org
465
+
466
+ # Call a tool with inline JSON payload
467
+ npx @adcp/client a2a https://agent.example.com get_products '{"brief":"Coffee brands"}'
468
+
469
+ # Use a file for payload
470
+ npx @adcp/client mcp https://agent.example.com create_media_buy @payload.json
471
+
472
+ # With authentication
473
+ npx @adcp/client mcp https://agent.example.com get_products '{"brief":"..."}' --auth your-token
474
+
475
+ # JSON output for scripting
476
+ npx @adcp/client mcp https://agent.example.com get_products '{"brief":"..."}' --json
477
+ ```
478
+
479
+ **MCP Endpoint Discovery**: The client automatically discovers MCP endpoints. Just provide any URL - it tests your path first, and if no MCP server responds, it tries adding `/mcp`. Works with root domains, custom paths, anything.
480
+
481
+ See [docs/CLI.md](docs/CLI.md) for complete CLI documentation including webhook support for async operations.
482
+
458
483
  ## Testing
459
484
 
460
485
  Try the live testing UI at `http://localhost:8080` when running the server:
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Async webhook handler for AdCP CLI
5
+ *
6
+ * This module handles async/webhook responses by:
7
+ * 1. Starting a temporary HTTP server for webhooks
8
+ * 2. Using ngrok to expose the server publicly (if available)
9
+ * 3. Waiting for the async response
10
+ */
11
+
12
+ const http = require('http');
13
+ const { spawn } = require('child_process');
14
+ const { randomUUID } = require('crypto');
15
+
16
+ class AsyncWebhookHandler {
17
+ constructor(options = {}) {
18
+ this.port = options.port || 0; // 0 = random available port
19
+ this.timeout = options.timeout || 300000; // 5 minutes default
20
+ this.debug = options.debug || false;
21
+ this.server = null;
22
+ this.ngrokProcess = null;
23
+ this.webhookUrl = null;
24
+ this.operationId = randomUUID();
25
+ this.responsePromise = null;
26
+ this.responseResolver = null;
27
+ }
28
+
29
+ /**
30
+ * Check if ngrok is installed
31
+ */
32
+ static isNgrokAvailable() {
33
+ return new Promise((resolve) => {
34
+ const check = spawn('which', ['ngrok']);
35
+ check.on('close', (code) => resolve(code === 0));
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Start the webhook server and optionally ngrok tunnel
41
+ * @param {boolean} useNgrok - Whether to use ngrok (default: true)
42
+ */
43
+ async start(useNgrok = true) {
44
+ // Create the promise that will resolve when we get the webhook
45
+ this.responsePromise = new Promise((resolve, reject) => {
46
+ this.responseResolver = resolve;
47
+ this.responseRejector = reject;
48
+
49
+ // Set timeout
50
+ setTimeout(() => {
51
+ reject(new Error(`Webhook timeout after ${this.timeout}ms`));
52
+ }, this.timeout);
53
+ });
54
+
55
+ // Start HTTP server
56
+ await this.startServer();
57
+
58
+ if (useNgrok) {
59
+ // Start ngrok tunnel
60
+ const ngrokAvailable = await AsyncWebhookHandler.isNgrokAvailable();
61
+ if (ngrokAvailable) {
62
+ await this.startNgrok();
63
+ } else {
64
+ throw new Error('ngrok is not installed. Install it with: brew install ngrok (Mac) or download from https://ngrok.com');
65
+ }
66
+ } else {
67
+ // Use local URL (for local agents)
68
+ this.webhookUrl = `http://localhost:${this.port}`;
69
+
70
+ if (this.debug) {
71
+ console.error(`✅ Local webhook server ready: ${this.webhookUrl}`);
72
+ }
73
+ }
74
+
75
+ return this.webhookUrl;
76
+ }
77
+
78
+ /**
79
+ * Start the HTTP server
80
+ */
81
+ startServer() {
82
+ return new Promise((resolve, reject) => {
83
+ this.server = http.createServer((req, res) => {
84
+ if (req.method === 'POST') {
85
+ let body = '';
86
+
87
+ req.on('data', chunk => {
88
+ body += chunk.toString();
89
+ });
90
+
91
+ req.on('end', () => {
92
+ try {
93
+ const payload = JSON.parse(body);
94
+
95
+ if (this.debug) {
96
+ console.error('\n🎣 Webhook received:');
97
+ console.error(JSON.stringify(payload, null, 2));
98
+ }
99
+
100
+ // Send 202 Accepted response
101
+ res.writeHead(202, { 'Content-Type': 'application/json' });
102
+ res.end(JSON.stringify({ status: 'accepted' }));
103
+
104
+ // Resolve the promise with the webhook payload
105
+ if (this.responseResolver) {
106
+ this.responseResolver(payload);
107
+ }
108
+ } catch (error) {
109
+ if (this.debug) {
110
+ console.error('Error parsing webhook:', error);
111
+ }
112
+ res.writeHead(400, { 'Content-Type': 'application/json' });
113
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
114
+ }
115
+ });
116
+ } else {
117
+ // Health check endpoint
118
+ res.writeHead(200, { 'Content-Type': 'application/json' });
119
+ res.end(JSON.stringify({ status: 'ready', operation_id: this.operationId }));
120
+ }
121
+ });
122
+
123
+ this.server.listen(this.port, () => {
124
+ const address = this.server.address();
125
+ this.port = address.port;
126
+
127
+ if (this.debug) {
128
+ console.error(`✅ Webhook server listening on port ${this.port}`);
129
+ }
130
+
131
+ resolve();
132
+ });
133
+
134
+ this.server.on('error', reject);
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Start ngrok tunnel
140
+ */
141
+ startNgrok() {
142
+ return new Promise((resolve, reject) => {
143
+ if (this.debug) {
144
+ console.error(`🚇 Starting ngrok tunnel for port ${this.port}...`);
145
+ }
146
+
147
+ // Start ngrok with JSON output for easier parsing
148
+ this.ngrokProcess = spawn('ngrok', ['http', String(this.port), '--log=stdout', '--log-format=json']);
149
+
150
+ let ngrokStarted = false;
151
+ let buffer = '';
152
+
153
+ this.ngrokProcess.stdout.on('data', (data) => {
154
+ buffer += data.toString();
155
+ const lines = buffer.split('\n');
156
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
157
+
158
+ for (const line of lines) {
159
+ if (!line.trim()) continue;
160
+
161
+ try {
162
+ const parsed = JSON.parse(line);
163
+
164
+ // Look for the tunnel URL in ngrok's JSON output
165
+ if (parsed.url && parsed.url.startsWith('https://')) {
166
+ this.webhookUrl = parsed.url;
167
+ ngrokStarted = true;
168
+
169
+ if (this.debug) {
170
+ console.error(`✅ ngrok tunnel ready: ${this.webhookUrl}`);
171
+ }
172
+
173
+ resolve();
174
+ }
175
+ } catch (e) {
176
+ // Not JSON, might be plain text output
177
+ // Try to extract URL from plain text
178
+ const urlMatch = line.match(/https:\/\/[a-z0-9-]+\.ngrok(?:-free)?\.(?:app|io)/);
179
+ if (urlMatch && !ngrokStarted) {
180
+ this.webhookUrl = urlMatch[0];
181
+ ngrokStarted = true;
182
+
183
+ if (this.debug) {
184
+ console.error(`✅ ngrok tunnel ready: ${this.webhookUrl}`);
185
+ }
186
+
187
+ resolve();
188
+ }
189
+ }
190
+ }
191
+ });
192
+
193
+ this.ngrokProcess.stderr.on('data', (data) => {
194
+ if (this.debug) {
195
+ console.error('ngrok stderr:', data.toString());
196
+ }
197
+ });
198
+
199
+ this.ngrokProcess.on('error', (error) => {
200
+ reject(new Error(`Failed to start ngrok: ${error.message}`));
201
+ });
202
+
203
+ this.ngrokProcess.on('close', (code) => {
204
+ if (!ngrokStarted && code !== 0) {
205
+ reject(new Error(`ngrok exited with code ${code}`));
206
+ }
207
+ });
208
+
209
+ // Timeout for ngrok startup
210
+ setTimeout(() => {
211
+ if (!ngrokStarted) {
212
+ reject(new Error('ngrok failed to start within 10 seconds'));
213
+ }
214
+ }, 10000);
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Wait for the webhook response
220
+ */
221
+ async waitForResponse() {
222
+ if (this.debug) {
223
+ console.error('\n⏳ Waiting for async response...');
224
+ }
225
+
226
+ const startTime = Date.now();
227
+ const result = await this.responsePromise;
228
+ const duration = Date.now() - startTime;
229
+
230
+ if (this.debug) {
231
+ console.error(`✅ Response received after ${(duration / 1000).toFixed(1)}s\n`);
232
+ }
233
+
234
+ return result;
235
+ }
236
+
237
+ /**
238
+ * Clean up resources
239
+ */
240
+ async cleanup() {
241
+ if (this.debug) {
242
+ console.error('🧹 Cleaning up...');
243
+ }
244
+
245
+ // Close HTTP server
246
+ if (this.server) {
247
+ await new Promise((resolve) => {
248
+ this.server.close(() => resolve());
249
+ });
250
+ }
251
+
252
+ // Kill ngrok process
253
+ if (this.ngrokProcess) {
254
+ this.ngrokProcess.kill();
255
+ }
256
+
257
+ if (this.debug) {
258
+ console.error('✅ Cleanup complete');
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Get the webhook URL with operation ID
264
+ */
265
+ getWebhookUrl() {
266
+ if (!this.webhookUrl) {
267
+ throw new Error('Webhook server not started');
268
+ }
269
+ return `${this.webhookUrl}?operation_id=${this.operationId}`;
270
+ }
271
+ }
272
+
273
+ module.exports = { AsyncWebhookHandler };
package/bin/adcp.js ADDED
@@ -0,0 +1,358 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * AdCP CLI Tool
5
+ *
6
+ * Simple command-line utility to call AdCP agents directly
7
+ *
8
+ * Usage:
9
+ * adcp <protocol> <agent-url> <tool-name> [payload-json] [--auth token]
10
+ *
11
+ * Examples:
12
+ * adcp mcp https://agent.example.com/mcp get_products '{"brief":"coffee brands"}'
13
+ * adcp a2a https://agent.example.com list_creative_formats '{}' --auth your_token_here
14
+ * adcp mcp https://agent.example.com/mcp create_media_buy @payload.json --auth $AGENT_TOKEN
15
+ */
16
+
17
+ const { ADCPClient } = require('../dist/lib/index.js');
18
+ const { readFileSync } = require('fs');
19
+ const { AsyncWebhookHandler } = require('./adcp-async-handler.js');
20
+
21
+ /**
22
+ * Display agent info - just calls library method
23
+ */
24
+ async function displayAgentInfo(agentConfig, jsonOutput) {
25
+ const client = new ADCPClient(agentConfig);
26
+ const info = await client.getAgentInfo();
27
+
28
+ if (jsonOutput) {
29
+ console.log(JSON.stringify(info, null, 2));
30
+ } else {
31
+ console.log(`\n📋 Agent Information\n`);
32
+ console.log(`Name: ${info.name}`);
33
+ if (info.description) {
34
+ console.log(`Description: ${info.description}`);
35
+ }
36
+ console.log(`Protocol: ${info.protocol.toUpperCase()}`);
37
+ console.log(`URL: ${info.url}`);
38
+ console.log(`\nAvailable Tools (${info.tools.length}):\n`);
39
+
40
+ if (info.tools.length === 0) {
41
+ console.log('No tools found.');
42
+ } else {
43
+ info.tools.forEach((tool, i) => {
44
+ console.log(`${i + 1}. ${tool.name}`);
45
+ if (tool.description) {
46
+ console.log(` ${tool.description}`);
47
+ }
48
+ if (tool.parameters && tool.parameters.length > 0) {
49
+ console.log(` Parameters: ${tool.parameters.join(', ')}`);
50
+ }
51
+ console.log('');
52
+ });
53
+ }
54
+ }
55
+ }
56
+
57
+ function printUsage() {
58
+ console.log(`
59
+ AdCP CLI Tool - Direct Agent Communication
60
+
61
+ USAGE:
62
+ adcp <protocol> <agent-url> [tool-name] [payload] [options]
63
+
64
+ ARGUMENTS:
65
+ protocol Protocol to use: 'mcp' or 'a2a'
66
+ agent-url Full URL to the agent endpoint
67
+ tool-name Name of the tool to call (optional - omit to list available tools)
68
+ payload JSON payload for the tool (default: {})
69
+ - Can be inline JSON: '{"brief":"text"}'
70
+ - Can be file path: @payload.json
71
+ - Can be stdin: -
72
+
73
+ OPTIONS:
74
+ --auth TOKEN Authentication token for the agent
75
+ --wait Wait for async/webhook responses (requires ngrok or --local)
76
+ --local Use local webhook without ngrok (for local agents only)
77
+ --timeout MS Webhook timeout in milliseconds (default: 300000 = 5min)
78
+ --help, -h Show this help message
79
+ --json Output raw JSON response (default: pretty print)
80
+ --debug Show debug information
81
+
82
+ EXAMPLES:
83
+ # List available tools
84
+ adcp mcp https://agent.example.com/mcp
85
+ adcp a2a https://creative.adcontextprotocol.org
86
+
87
+ # Simple product discovery
88
+ adcp mcp https://agent.example.com/mcp get_products '{"brief":"coffee brands"}'
89
+
90
+ # With authentication
91
+ adcp a2a https://agent.example.com list_creative_formats '{}' --auth your_token
92
+
93
+ # Wait for async response (requires ngrok)
94
+ adcp mcp https://agent.example.com/mcp create_media_buy @payload.json --auth $TOKEN --wait
95
+
96
+ # Wait for async response from local agent (no ngrok needed)
97
+ adcp mcp http://localhost:3000/mcp create_media_buy @payload.json --wait --local
98
+
99
+ # From file
100
+ adcp mcp https://agent.example.com/mcp create_media_buy @payload.json --auth $TOKEN
101
+
102
+ # From stdin
103
+ echo '{"brief":"travel"}' | adcp mcp https://agent.example.com/mcp get_products -
104
+
105
+ ENVIRONMENT VARIABLES:
106
+ ADCP_AUTH_TOKEN Default authentication token (overridden by --auth)
107
+ ADCP_DEBUG Enable debug mode (set to 'true')
108
+
109
+ EXIT CODES:
110
+ 0 Success
111
+ 1 General error
112
+ 2 Invalid arguments
113
+ 3 Agent error
114
+ `);
115
+ }
116
+
117
+ async function main() {
118
+ const args = process.argv.slice(2);
119
+
120
+ // Handle help
121
+ if (args.includes('--help') || args.includes('-h') || args.length === 0) {
122
+ printUsage();
123
+ process.exit(0);
124
+ }
125
+
126
+ // Parse arguments
127
+ if (args.length < 2) {
128
+ console.error('ERROR: Missing required arguments\n');
129
+ printUsage();
130
+ process.exit(2);
131
+ }
132
+
133
+ // Parse options first
134
+ const authIndex = args.indexOf('--auth');
135
+ const authToken = authIndex !== -1 ? args[authIndex + 1] : process.env.ADCP_AUTH_TOKEN;
136
+ const jsonOutput = args.includes('--json');
137
+ const debug = args.includes('--debug') || process.env.ADCP_DEBUG === 'true';
138
+ const waitForAsync = args.includes('--wait');
139
+ const useLocalWebhook = args.includes('--local');
140
+ const timeoutIndex = args.indexOf('--timeout');
141
+ const timeout = timeoutIndex !== -1 ? parseInt(args[timeoutIndex + 1]) : 300000;
142
+
143
+ // Filter out flag arguments to find positional arguments
144
+ const positionalArgs = args.filter(arg =>
145
+ !arg.startsWith('--') &&
146
+ arg !== authToken && // Don't include the auth token value
147
+ arg !== (timeoutIndex !== -1 ? args[timeoutIndex + 1] : null) // Don't include timeout value
148
+ );
149
+
150
+ const protocol = positionalArgs[0];
151
+ const agentUrl = positionalArgs[1];
152
+ const toolName = positionalArgs[2]; // Optional - if not provided, list tools
153
+ let payloadArg = positionalArgs[3] || '{}';
154
+
155
+ // Validate protocol
156
+ if (protocol !== 'mcp' && protocol !== 'a2a') {
157
+ console.error(`ERROR: Invalid protocol '${protocol}'. Must be 'mcp' or 'a2a'\n`);
158
+ printUsage();
159
+ process.exit(2);
160
+ }
161
+
162
+ // Parse payload
163
+ let payload;
164
+ try {
165
+ if (payloadArg === '-') {
166
+ // Read from stdin
167
+ const stdin = readFileSync(0, 'utf-8');
168
+ payload = JSON.parse(stdin);
169
+ } else if (payloadArg.startsWith('@')) {
170
+ // Read from file
171
+ const filePath = payloadArg.substring(1);
172
+ const fileContent = readFileSync(filePath, 'utf-8');
173
+ payload = JSON.parse(fileContent);
174
+ } else {
175
+ // Parse inline JSON
176
+ payload = JSON.parse(payloadArg);
177
+ }
178
+ } catch (error) {
179
+ console.error(`ERROR: Invalid JSON payload: ${error.message}\n`);
180
+ process.exit(2);
181
+ }
182
+
183
+ if (debug) {
184
+ console.error('DEBUG: Configuration');
185
+ console.error(` Protocol: ${protocol}`);
186
+ console.error(` Agent URL: ${agentUrl}`);
187
+ console.error(` Tool: ${toolName}`);
188
+ console.error(` Auth: ${authToken ? 'provided' : 'none'}`);
189
+ console.error(` Payload: ${JSON.stringify(payload, null, 2)}`);
190
+ console.error('');
191
+ }
192
+
193
+ // Create agent config
194
+ const agentConfig = {
195
+ id: 'cli-agent',
196
+ name: 'CLI Agent',
197
+ agent_uri: agentUrl,
198
+ protocol: protocol,
199
+ ...(authToken && { auth_token_env: authToken })
200
+ };
201
+
202
+ try {
203
+ // If no tool name provided, display agent info
204
+ if (!toolName) {
205
+ if (debug) {
206
+ console.error('DEBUG: No tool specified, displaying agent info...\n');
207
+ }
208
+
209
+ await displayAgentInfo(agentConfig, jsonOutput);
210
+ process.exit(0);
211
+ }
212
+
213
+ // Set up webhook handler if --wait flag is used
214
+ let webhookHandler = null;
215
+ let webhookUrl = null;
216
+
217
+ if (waitForAsync) {
218
+ const useNgrok = !useLocalWebhook;
219
+
220
+ // Check if ngrok is available (unless using --local)
221
+ if (useNgrok) {
222
+ const ngrokAvailable = await AsyncWebhookHandler.isNgrokAvailable();
223
+ if (!ngrokAvailable) {
224
+ console.error('\n❌ ERROR: --wait flag requires ngrok to be installed\n');
225
+ console.error('Install ngrok:');
226
+ console.error(' Mac: brew install ngrok');
227
+ console.error(' Windows: choco install ngrok');
228
+ console.error(' Linux: Download from https://ngrok.com/download');
229
+ console.error('\nOr use --local flag for local agents (e.g., http://localhost:3000)\n');
230
+ process.exit(2);
231
+ }
232
+ }
233
+
234
+ if (debug) {
235
+ console.error(`DEBUG: Setting up ${useNgrok ? 'ngrok' : 'local'} webhook handler...\n`);
236
+ }
237
+
238
+ webhookHandler = new AsyncWebhookHandler({
239
+ timeout: timeout,
240
+ debug: debug
241
+ });
242
+
243
+ try {
244
+ webhookUrl = await webhookHandler.start(useNgrok);
245
+
246
+ if (!jsonOutput) {
247
+ console.log(`\n🌐 ${useNgrok ? 'Public webhook' : 'Local webhook'} endpoint ready`);
248
+ console.log(` URL: ${webhookUrl}`);
249
+ console.log(` Timeout: ${timeout / 1000}s`);
250
+ if (useLocalWebhook) {
251
+ console.log(` ⚠️ Local mode: Agent must be accessible at localhost`);
252
+ }
253
+ console.log('');
254
+ }
255
+ } catch (error) {
256
+ console.error('\n❌ ERROR: Failed to start webhook handler\n');
257
+ console.error(error.message);
258
+ if (debug) {
259
+ console.error('\nStack trace:');
260
+ console.error(error.stack);
261
+ }
262
+ process.exit(1);
263
+ }
264
+ }
265
+
266
+ // Create ADCP client with optional webhook configuration
267
+ const client = new ADCPClient(agentConfig, {
268
+ debug: debug,
269
+ ...(webhookUrl && {
270
+ webhookUrlTemplate: webhookUrl,
271
+ webhookSecret: 'cli-webhook-secret'
272
+ })
273
+ });
274
+
275
+ if (debug) {
276
+ console.error('DEBUG: Executing task...\n');
277
+ }
278
+
279
+ // Execute the task
280
+ const result = await client.executeTask(toolName, payload);
281
+
282
+ // If waiting for async response, handle webhook
283
+ if (waitForAsync && webhookHandler) {
284
+ if (result.status === 'submitted' || result.status === 'working') {
285
+ if (!jsonOutput) {
286
+ console.log('📤 Task submitted, waiting for async response...');
287
+ }
288
+
289
+ try {
290
+ const webhookResponse = await webhookHandler.waitForResponse();
291
+
292
+ // Clean up webhook handler
293
+ await webhookHandler.cleanup();
294
+
295
+ // Output webhook response
296
+ if (jsonOutput) {
297
+ console.log(JSON.stringify(webhookResponse.result || webhookResponse, null, 2));
298
+ } else {
299
+ console.log('\n✅ ASYNC RESPONSE RECEIVED\n');
300
+ console.log('Response:');
301
+ console.log(JSON.stringify(webhookResponse.result || webhookResponse, null, 2));
302
+ }
303
+
304
+ process.exit(0);
305
+ } catch (error) {
306
+ await webhookHandler.cleanup();
307
+ console.error('\n❌ WEBHOOK TIMEOUT\n');
308
+ console.error(error.message);
309
+ process.exit(3);
310
+ }
311
+ } else {
312
+ // Task completed synchronously, clean up webhook
313
+ await webhookHandler.cleanup();
314
+ }
315
+ }
316
+
317
+ // Handle result
318
+ if (result.success) {
319
+ if (jsonOutput) {
320
+ // Raw JSON output
321
+ console.log(JSON.stringify(result.data, null, 2));
322
+ } else {
323
+ // Pretty output
324
+ console.log('\n✅ SUCCESS\n');
325
+ console.log('Response:');
326
+ console.log(JSON.stringify(result.data, null, 2));
327
+ console.log('');
328
+ console.log(`Response Time: ${result.metadata.responseTimeMs}ms`);
329
+ console.log(`Task ID: ${result.metadata.taskId}`);
330
+ }
331
+ process.exit(0);
332
+ } else {
333
+ console.error('\n❌ TASK FAILED\n');
334
+ console.error(`Error: ${result.error || 'Unknown error'}`);
335
+ if (result.metadata?.clarificationRounds) {
336
+ console.error(`Clarifications: ${result.metadata.clarificationRounds}`);
337
+ }
338
+ if (debug && result.metadata) {
339
+ console.error('\nMetadata:');
340
+ console.error(JSON.stringify(result.metadata, null, 2));
341
+ }
342
+ process.exit(3);
343
+ }
344
+ } catch (error) {
345
+ console.error('\n❌ ERROR\n');
346
+ console.error(error.message);
347
+ if (debug) {
348
+ console.error('\nStack trace:');
349
+ console.error(error.stack);
350
+ }
351
+ process.exit(1);
352
+ }
353
+ }
354
+
355
+ main().catch(error => {
356
+ console.error('FATAL ERROR:', error.message);
357
+ process.exit(1);
358
+ });