a2acalling 0.6.35 → 0.6.37
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/bin/cli.js +353 -126
- package/docs/plans/2026-02-15-port-fallback-warning-design.md +611 -0
- package/package.json +1 -1
- package/src/lib/conversation-driver.js +36 -12
package/bin/cli.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const os = require('os');
|
|
20
20
|
const path = require('path');
|
|
21
|
+
const crypto = require('crypto');
|
|
21
22
|
const { spawn } = require('child_process');
|
|
22
23
|
const { TokenStore } = require('../src/lib/tokens');
|
|
23
24
|
const { A2AClient } = require('../src/lib/client');
|
|
@@ -296,6 +297,166 @@ function summarizePortResults(portResults) {
|
|
|
296
297
|
});
|
|
297
298
|
}
|
|
298
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Identify what process is using a given port.
|
|
302
|
+
* Returns { pid, name } or null if detection fails.
|
|
303
|
+
*/
|
|
304
|
+
function identifyPortProcess(port) {
|
|
305
|
+
const p = Number(port);
|
|
306
|
+
if (!Number.isFinite(p) || p <= 0) return null;
|
|
307
|
+
const { execSync } = require('child_process');
|
|
308
|
+
// Try lsof first (most common on Linux/macOS)
|
|
309
|
+
try {
|
|
310
|
+
const out = execSync(`lsof -i :${p} -sTCP:LISTEN -t 2>/dev/null`, { encoding: 'utf8', timeout: 5000 }).trim();
|
|
311
|
+
if (out) {
|
|
312
|
+
const pid = out.split('\n')[0].trim();
|
|
313
|
+
let name = 'unknown';
|
|
314
|
+
try {
|
|
315
|
+
name = execSync(`ps -p ${pid} -o comm= 2>/dev/null`, { encoding: 'utf8', timeout: 3000 }).trim();
|
|
316
|
+
} catch (e) { /* best-effort */ }
|
|
317
|
+
return { pid: Number(pid), name };
|
|
318
|
+
}
|
|
319
|
+
} catch (e) { /* lsof not available or failed */ }
|
|
320
|
+
|
|
321
|
+
// Fallback: ss (Linux)
|
|
322
|
+
try {
|
|
323
|
+
const out = execSync(`ss -tlnp 'sport = :${p}' 2>/dev/null`, { encoding: 'utf8', timeout: 5000 });
|
|
324
|
+
const pidMatch = out.match(/pid=(\d+)/);
|
|
325
|
+
const nameMatch = out.match(/\("([^"]+)"/);
|
|
326
|
+
if (pidMatch) {
|
|
327
|
+
return { pid: Number(pidMatch[1]), name: nameMatch ? nameMatch[1] : 'unknown' };
|
|
328
|
+
}
|
|
329
|
+
} catch (e) { /* ss not available */ }
|
|
330
|
+
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* When port 80 is unavailable, prompt the user with fallback options.
|
|
336
|
+
* Returns { strategy: 'kill' | 'proxy' | 'continue', port: number }
|
|
337
|
+
*
|
|
338
|
+
* Non-interactive: auto-returns 'continue' with a printed warning.
|
|
339
|
+
*/
|
|
340
|
+
async function promptPortFallbackStrategy(fallbackPort, interactive) {
|
|
341
|
+
const processInfo = identifyPortProcess(80);
|
|
342
|
+
|
|
343
|
+
console.log('\n ┌─────────────────────────────────────────────────────────────────┐');
|
|
344
|
+
console.log(' │ ⚠ PORT 80 IS UNAVAILABLE │');
|
|
345
|
+
console.log(' └─────────────────────────────────────────────────────────────────┘');
|
|
346
|
+
console.log('');
|
|
347
|
+
if (processInfo) {
|
|
348
|
+
console.log(` Port 80 is held by: ${processInfo.name} (PID ${processInfo.pid})`);
|
|
349
|
+
} else {
|
|
350
|
+
console.log(' Port 80 is in use by another process (could not identify).');
|
|
351
|
+
}
|
|
352
|
+
console.log('');
|
|
353
|
+
console.log(' Why this matters:');
|
|
354
|
+
console.log(' - Port 80 is the default HTTP port — no firewall config needed');
|
|
355
|
+
console.log(` - Fallback port ${fallbackPort} may be blocked by the caller's firewall`);
|
|
356
|
+
console.log(` - If the server restarts on a different port, all invite URLs break`);
|
|
357
|
+
console.log(` - Invite URLs with non-standard ports look like: a2a://host:${fallbackPort}/token`);
|
|
358
|
+
console.log('');
|
|
359
|
+
|
|
360
|
+
if (!interactive) {
|
|
361
|
+
console.log(` Non-interactive mode: continuing on port ${fallbackPort}.`);
|
|
362
|
+
console.log(' Set up a reverse proxy (port 80 → ' + fallbackPort + ') for production use.\n');
|
|
363
|
+
return { strategy: 'continue', port: fallbackPort };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
console.log(' Options:');
|
|
367
|
+
if (processInfo) {
|
|
368
|
+
console.log(` 1) Kill ${processInfo.name} (PID ${processInfo.pid}) and use port 80`);
|
|
369
|
+
} else {
|
|
370
|
+
console.log(' 1) Kill the process on port 80 and retry');
|
|
371
|
+
}
|
|
372
|
+
console.log(` 2) Set up a reverse proxy (port 80 → ${fallbackPort})`);
|
|
373
|
+
console.log(` 3) Continue on port ${fallbackPort} (not recommended for production)`);
|
|
374
|
+
console.log('');
|
|
375
|
+
|
|
376
|
+
const choice = await promptText(' Choose [1/2/3]: ', '2');
|
|
377
|
+
const normalized = String(choice).trim();
|
|
378
|
+
|
|
379
|
+
if (normalized === '1') {
|
|
380
|
+
return { strategy: 'kill', port: 80, processInfo };
|
|
381
|
+
} else if (normalized === '3') {
|
|
382
|
+
console.log(`\n ⚠ Continuing on port ${fallbackPort}.`);
|
|
383
|
+
console.log(` Invite URLs will include :${fallbackPort} and may not be reachable externally.`);
|
|
384
|
+
console.log(' You can set up a reverse proxy later with: a2a config --help\n');
|
|
385
|
+
return { strategy: 'continue', port: fallbackPort };
|
|
386
|
+
} else {
|
|
387
|
+
// Default: reverse proxy (option 2)
|
|
388
|
+
return { strategy: 'proxy', port: fallbackPort };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Attempt to kill the process on a given port.
|
|
394
|
+
* Returns true if kill succeeded and port is now available.
|
|
395
|
+
*/
|
|
396
|
+
async function killPortProcess(processInfo) {
|
|
397
|
+
if (!processInfo || !Number.isFinite(processInfo.pid)) return false;
|
|
398
|
+
const { execSync } = require('child_process');
|
|
399
|
+
try {
|
|
400
|
+
console.log(` Killing ${processInfo.name} (PID ${processInfo.pid})...`);
|
|
401
|
+
execSync(`kill ${processInfo.pid}`, { timeout: 5000 });
|
|
402
|
+
// Wait briefly for the port to free up
|
|
403
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
404
|
+
const { tryBindPort } = require('../src/lib/port-scanner');
|
|
405
|
+
const result = await tryBindPort(80);
|
|
406
|
+
if (result.ok) {
|
|
407
|
+
console.log(' ✅ Port 80 is now available.');
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
console.log(' Port 80 is still in use after kill. The process may require sudo to stop.');
|
|
411
|
+
return false;
|
|
412
|
+
} catch (e) {
|
|
413
|
+
console.log(` Could not kill process: ${e.message}`);
|
|
414
|
+
console.log(' You may need to run: sudo kill ' + processInfo.pid);
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Detect installed web servers and generate reverse proxy config.
|
|
421
|
+
* Returns { hasNginx, hasCaddy, nginxConfig, caddyConfig }.
|
|
422
|
+
*/
|
|
423
|
+
function generateProxyConfig(backendPort) {
|
|
424
|
+
const { spawnSync } = require('child_process');
|
|
425
|
+
const hasNginx = spawnSync('which', ['nginx'], { encoding: 'utf8' }).status === 0;
|
|
426
|
+
const hasCaddy = spawnSync('which', ['caddy'], { encoding: 'utf8' }).status === 0;
|
|
427
|
+
|
|
428
|
+
const nginxConfig = [
|
|
429
|
+
'# ══════════════════════════════════════════════════════════════',
|
|
430
|
+
'# A2A (Agent-to-Agent) Protocol Proxy',
|
|
431
|
+
'# ══════════════════════════════════════════════════════════════',
|
|
432
|
+
'# Routes federation requests from port 80 to the local',
|
|
433
|
+
`# A2A server on port ${backendPort}.`,
|
|
434
|
+
'#',
|
|
435
|
+
'# Protocol: https://github.com/onthegonow/a2a_calling',
|
|
436
|
+
'# All requests to /api/a2a/* are agent-to-agent API calls.',
|
|
437
|
+
'# ══════════════════════════════════════════════════════════════',
|
|
438
|
+
'location /api/a2a/ {',
|
|
439
|
+
` proxy_pass http://127.0.0.1:${backendPort}/api/a2a/;`,
|
|
440
|
+
' proxy_http_version 1.1;',
|
|
441
|
+
' proxy_set_header Host $host;',
|
|
442
|
+
' proxy_set_header X-Real-IP $remote_addr;',
|
|
443
|
+
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;',
|
|
444
|
+
' proxy_set_header X-Forwarded-Proto $scheme;',
|
|
445
|
+
'}'
|
|
446
|
+
].join('\n');
|
|
447
|
+
|
|
448
|
+
const caddyConfig = [
|
|
449
|
+
'# A2A (Agent-to-Agent) Protocol Proxy',
|
|
450
|
+
`# Routes federation requests to local A2A server on port ${backendPort}`,
|
|
451
|
+
'# Protocol: https://github.com/onthegonow/a2a_calling',
|
|
452
|
+
'handle /api/a2a/* {',
|
|
453
|
+
` reverse_proxy 127.0.0.1:${backendPort}`,
|
|
454
|
+
'}'
|
|
455
|
+
].join('\n');
|
|
456
|
+
|
|
457
|
+
return { hasNginx, hasCaddy, nginxConfig, caddyConfig };
|
|
458
|
+
}
|
|
459
|
+
|
|
299
460
|
async function handleDisclosureSubmit(args, commandLabel = 'onboard') {
|
|
300
461
|
const submitRaw = args.flags.submit;
|
|
301
462
|
if (!submitRaw) return false;
|
|
@@ -1204,28 +1365,45 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
|
|
|
1204
1365
|
|
|
1205
1366
|
// Persist conversation locally
|
|
1206
1367
|
const cs = getConvStore();
|
|
1207
|
-
if (cs
|
|
1368
|
+
if (cs) {
|
|
1208
1369
|
try {
|
|
1209
|
-
|
|
1210
|
-
|
|
1370
|
+
// Use remote conversation ID if provided, otherwise generate a local one
|
|
1371
|
+
const convId = response.conversation_id || `conv_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
1372
|
+
|
|
1373
|
+
const convResult = cs.startConversation({
|
|
1374
|
+
id: convId,
|
|
1211
1375
|
contactId: contactName || null,
|
|
1212
1376
|
contactName: contactName || null,
|
|
1213
1377
|
direction: 'outbound'
|
|
1214
1378
|
});
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
direction: 'inbound',
|
|
1223
|
-
role: 'assistant',
|
|
1224
|
-
content: response.response
|
|
1379
|
+
if (convResult.success === false) {
|
|
1380
|
+
console.error(`⚠️ Failed to save conversation: ${convResult.error}`);
|
|
1381
|
+
} else {
|
|
1382
|
+
const outMsg = cs.addMessage(convId, {
|
|
1383
|
+
direction: 'outbound',
|
|
1384
|
+
role: 'user',
|
|
1385
|
+
content: message
|
|
1225
1386
|
});
|
|
1387
|
+
if (outMsg.success === false) {
|
|
1388
|
+
console.error(`⚠️ Failed to save outbound message: ${outMsg.error}`);
|
|
1389
|
+
}
|
|
1390
|
+
if (response.response) {
|
|
1391
|
+
const inMsg = cs.addMessage(convId, {
|
|
1392
|
+
direction: 'inbound',
|
|
1393
|
+
role: 'assistant',
|
|
1394
|
+
content: response.response
|
|
1395
|
+
});
|
|
1396
|
+
if (inMsg.success === false) {
|
|
1397
|
+
console.error(`⚠️ Failed to save inbound message: ${inMsg.error}`);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
// Update response to include conversation ID for display
|
|
1401
|
+
if (!response.conversation_id) {
|
|
1402
|
+
response.conversation_id = convId;
|
|
1403
|
+
}
|
|
1226
1404
|
}
|
|
1227
1405
|
} catch (err) {
|
|
1228
|
-
|
|
1406
|
+
console.error(`⚠️ Error persisting conversation: ${err.message}`);
|
|
1229
1407
|
}
|
|
1230
1408
|
}
|
|
1231
1409
|
|
|
@@ -1474,11 +1652,8 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
|
|
|
1474
1652
|
let recommendedPort;
|
|
1475
1653
|
if (port80Available) {
|
|
1476
1654
|
recommendedPort = 80;
|
|
1477
|
-
console.log(' Port 80 is available — using it for easiest external access.');
|
|
1478
1655
|
} else if (availableCandidates.length) {
|
|
1479
1656
|
recommendedPort = availableCandidates[0].port;
|
|
1480
|
-
console.log(` Port 80 is in use. Using fallback port ${recommendedPort}.`);
|
|
1481
|
-
console.log(' (Reverse proxy or firewall config will be needed for external access.)');
|
|
1482
1657
|
} else {
|
|
1483
1658
|
recommendedPort = null;
|
|
1484
1659
|
}
|
|
@@ -1494,49 +1669,76 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
|
|
|
1494
1669
|
}
|
|
1495
1670
|
|
|
1496
1671
|
let serverPort = recommendedPort;
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
if (
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1672
|
+
let proxyStrategy = false; // true if user chose reverse proxy option
|
|
1673
|
+
|
|
1674
|
+
if (port80Available) {
|
|
1675
|
+
// Port 80 available — simple confirm
|
|
1676
|
+
console.log(' Port 80 is available — using it for easiest external access.');
|
|
1677
|
+
const portPrompt = `Use port 80? [Y/n]: `;
|
|
1678
|
+
const portChoice = await promptText(portPrompt, 'y');
|
|
1679
|
+
|
|
1680
|
+
if (!interactive) {
|
|
1681
|
+
serverPort = 80;
|
|
1682
|
+
} else if (!['', 'y', 'Y', 'yes', 'YES', 'ye'].includes(String(portChoice).trim())) {
|
|
1683
|
+
if (/^(n|no|custom|c)$/i.test(String(portChoice).trim())) {
|
|
1684
|
+
let customPort = null;
|
|
1685
|
+
while (customPort === null) {
|
|
1686
|
+
const raw = await promptText('Enter a custom port number: ', String(recommendedPort));
|
|
1687
|
+
const parsed = parsePort(raw, null);
|
|
1688
|
+
if (!parsed) {
|
|
1689
|
+
console.log(' Invalid port. Enter a value between 1 and 65535.');
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
const checked = await (async () => {
|
|
1693
|
+
const scan = await inspectPorts(parsed);
|
|
1694
|
+
return scan[0];
|
|
1695
|
+
})();
|
|
1696
|
+
if (!checked.available) {
|
|
1697
|
+
console.log(` Port ${parsed} is unavailable (${checked.code || 'in use'}).`);
|
|
1698
|
+
continue;
|
|
1699
|
+
}
|
|
1700
|
+
customPort = parsed;
|
|
1515
1701
|
}
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1702
|
+
serverPort = customPort;
|
|
1703
|
+
} else {
|
|
1704
|
+
const parsed = parsePort(portChoice, null);
|
|
1705
|
+
if (parsed) {
|
|
1706
|
+
const checked = await (async () => {
|
|
1707
|
+
const scan = await inspectPorts(parsed);
|
|
1708
|
+
return scan[0];
|
|
1709
|
+
})();
|
|
1710
|
+
if (!checked.available) {
|
|
1711
|
+
console.log(` Port ${parsed} is unavailable (${checked.code || 'in use'}).`);
|
|
1712
|
+
} else {
|
|
1713
|
+
serverPort = parsed;
|
|
1714
|
+
}
|
|
1523
1715
|
}
|
|
1524
|
-
customPort = parsed;
|
|
1525
1716
|
}
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1717
|
+
}
|
|
1718
|
+
} else {
|
|
1719
|
+
// Port 80 NOT available — show the fallback strategy prompt
|
|
1720
|
+
const fallback = await promptPortFallbackStrategy(recommendedPort, interactive);
|
|
1721
|
+
|
|
1722
|
+
if (fallback.strategy === 'kill') {
|
|
1723
|
+
const confirmKill = await promptYesNo(` Kill ${(fallback.processInfo && fallback.processInfo.name) || 'process'} (PID ${(fallback.processInfo && fallback.processInfo.pid) || '?'})? (y/N) `);
|
|
1724
|
+
if (confirmKill) {
|
|
1725
|
+
const killed = await killPortProcess(fallback.processInfo);
|
|
1726
|
+
if (killed) {
|
|
1727
|
+
serverPort = 80;
|
|
1536
1728
|
} else {
|
|
1537
|
-
|
|
1729
|
+
console.log(`\n Falling back to port ${recommendedPort}.`);
|
|
1730
|
+
console.log(' You can set up a reverse proxy after setup completes.\n');
|
|
1731
|
+
serverPort = recommendedPort;
|
|
1538
1732
|
}
|
|
1733
|
+
} else {
|
|
1734
|
+
console.log(` Skipped. Using port ${recommendedPort}.\n`);
|
|
1735
|
+
serverPort = recommendedPort;
|
|
1539
1736
|
}
|
|
1737
|
+
} else if (fallback.strategy === 'proxy') {
|
|
1738
|
+
serverPort = fallback.port;
|
|
1739
|
+
proxyStrategy = true;
|
|
1740
|
+
} else {
|
|
1741
|
+
serverPort = fallback.port;
|
|
1540
1742
|
}
|
|
1541
1743
|
}
|
|
1542
1744
|
|
|
@@ -1651,81 +1853,60 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
|
|
|
1651
1853
|
// Port 80 — optimal setup, no extra config needed
|
|
1652
1854
|
console.log(`\n ✅ Running on port 80 — external agents can reach you directly.`);
|
|
1653
1855
|
console.log(` Invite hostname: ${externalIp}`);
|
|
1654
|
-
// Update publicHost to not include port since 80 is default
|
|
1655
1856
|
publicHost = externalIp;
|
|
1656
|
-
} else {
|
|
1657
|
-
//
|
|
1658
|
-
const
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
console.log(
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
console.log(
|
|
1669
|
-
console.log(
|
|
1670
|
-
console.log(`
|
|
1671
|
-
console.log(
|
|
1672
|
-
console.log(
|
|
1673
|
-
console.log(
|
|
1674
|
-
console.log(
|
|
1675
|
-
console.log(
|
|
1676
|
-
console.log(
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
console.log(
|
|
1681
|
-
console.log(
|
|
1682
|
-
console.log(
|
|
1683
|
-
console.log(
|
|
1684
|
-
console.log(
|
|
1685
|
-
console.log(`
|
|
1686
|
-
console.log(` proxy_set_header Host $host;`);
|
|
1687
|
-
console.log(` proxy_set_header X-Real-IP $remote_addr;`);
|
|
1688
|
-
console.log(` proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`);
|
|
1689
|
-
console.log(` proxy_set_header X-Forwarded-Proto $scheme;`);
|
|
1690
|
-
console.log(` }`);
|
|
1691
|
-
console.log(``);
|
|
1692
|
-
console.log(` To apply:`);
|
|
1693
|
-
console.log(` 1. sudo nano /etc/nginx/sites-available/default`);
|
|
1694
|
-
console.log(` 2. Add the config above inside your server { } block`);
|
|
1695
|
-
console.log(` 3. sudo nginx -t`);
|
|
1696
|
-
console.log(` 4. sudo systemctl reload nginx`);
|
|
1857
|
+
} else if (proxyStrategy) {
|
|
1858
|
+
// User chose to set up a reverse proxy
|
|
1859
|
+
const proxy = generateProxyConfig(serverPort);
|
|
1860
|
+
|
|
1861
|
+
console.log(`\n ━━━ Reverse Proxy Configuration ━━━`);
|
|
1862
|
+
console.log(`\n A2A server running on port ${serverPort}. Configure your web server`);
|
|
1863
|
+
console.log(` to proxy port 80 → ${serverPort} so invite URLs work without a port number.\n`);
|
|
1864
|
+
|
|
1865
|
+
if (proxy.hasNginx) {
|
|
1866
|
+
console.log(' ┌─────────────────────────────────────────────────────────────────┐');
|
|
1867
|
+
console.log(' │ nginx — add inside your server {} block │');
|
|
1868
|
+
console.log(' │ File: /etc/nginx/sites-available/default │');
|
|
1869
|
+
console.log(' └─────────────────────────────────────────────────────────────────┘');
|
|
1870
|
+
console.log('');
|
|
1871
|
+
proxy.nginxConfig.split('\n').forEach(line => console.log(` ${line}`));
|
|
1872
|
+
console.log('');
|
|
1873
|
+
console.log(' To apply:');
|
|
1874
|
+
console.log(' 1. sudo nano /etc/nginx/sites-available/default');
|
|
1875
|
+
console.log(' 2. Add the config above inside your server { } block');
|
|
1876
|
+
console.log(' 3. sudo nginx -t');
|
|
1877
|
+
console.log(' 4. sudo systemctl reload nginx');
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
if (proxy.hasCaddy) {
|
|
1881
|
+
console.log('');
|
|
1882
|
+
console.log(' ┌─────────────────────────────────────────────────────────────────┐');
|
|
1883
|
+
console.log(' │ Caddy config │');
|
|
1884
|
+
console.log(' └─────────────────────────────────────────────────────────────────┘');
|
|
1885
|
+
console.log('');
|
|
1886
|
+
proxy.caddyConfig.split('\n').forEach(line => console.log(` ${line}`));
|
|
1697
1887
|
}
|
|
1698
|
-
|
|
1699
|
-
if (hasCaddy) {
|
|
1700
|
-
console.log(
|
|
1701
|
-
console.log(
|
|
1702
|
-
console.log(
|
|
1703
|
-
console.log(
|
|
1704
|
-
console.log(
|
|
1705
|
-
console.log(`
|
|
1706
|
-
console.log(` # Protocol: https://github.com/onthegonow/a2a_calling`);
|
|
1707
|
-
console.log(` handle /api/a2a/* {`);
|
|
1708
|
-
console.log(` reverse_proxy 127.0.0.1:${serverPort}`);
|
|
1709
|
-
console.log(` }`);
|
|
1888
|
+
|
|
1889
|
+
if (!proxy.hasNginx && !proxy.hasCaddy) {
|
|
1890
|
+
console.log(' No nginx or Caddy detected. Install one:');
|
|
1891
|
+
console.log(' sudo apt install nginx # Debian/Ubuntu');
|
|
1892
|
+
console.log(' sudo yum install nginx # RHEL/CentOS');
|
|
1893
|
+
console.log('');
|
|
1894
|
+
console.log(' Then add this proxy config:');
|
|
1895
|
+
proxy.nginxConfig.split('\n').forEach(line => console.log(` ${line}`));
|
|
1710
1896
|
}
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
console.log(
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
console.log(
|
|
1718
|
-
console.log(` Invite hostname
|
|
1719
|
-
|
|
1720
|
-
console.log(
|
|
1721
|
-
console.log(
|
|
1722
|
-
console.log(` 1. Configure reverse proxy (recommended) — add nginx/caddy config above`);
|
|
1723
|
-
console.log(` 2. OR open firewall for port ${serverPort} — run the ufw/firewall-cmd command`);
|
|
1724
|
-
console.log(` 3. OR skip (local-only, external agents cannot call)`);
|
|
1725
|
-
console.log(`\n Ask your owner which option to use, then proceed.`);
|
|
1726
|
-
console.log(` If reverse proxy is configured, update hostname with: a2a config --hostname ${externalIp}`);
|
|
1897
|
+
|
|
1898
|
+
// With reverse proxy, invite URLs use port 80 (no port in URL)
|
|
1899
|
+
console.log(`\n After applying, invite hostname will be: ${externalIp} (no port needed)`);
|
|
1900
|
+
publicHost = externalIp;
|
|
1901
|
+
} else {
|
|
1902
|
+
// User chose 'continue' on non-standard port — brief reminder
|
|
1903
|
+
console.log(`\n ⚠ Running on port ${serverPort} (non-standard).`);
|
|
1904
|
+
console.log(` Invite hostname: ${publicHost}`);
|
|
1905
|
+
console.log(`\n To set up a reverse proxy later:`);
|
|
1906
|
+
console.log(` a2a config --hostname ${externalIp}`);
|
|
1907
|
+
console.log(` Then configure nginx/caddy to proxy port 80 → ${serverPort}.`);
|
|
1727
1908
|
}
|
|
1728
|
-
|
|
1909
|
+
|
|
1729
1910
|
const verifyUrl = `http://${publicHost}/api/a2a/ping`;
|
|
1730
1911
|
console.log(`\n Verify: curl -s ${verifyUrl}`);
|
|
1731
1912
|
}
|
|
@@ -1841,9 +2022,55 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
|
|
|
1841
2022
|
}
|
|
1842
2023
|
}
|
|
1843
2024
|
|
|
2025
|
+
// Kill server by PID from config (detached process started by quickstart)
|
|
2026
|
+
function killServerPid() {
|
|
2027
|
+
try {
|
|
2028
|
+
const { A2AConfig } = require('../src/lib/config');
|
|
2029
|
+
const config = new A2AConfig();
|
|
2030
|
+
const onboarding = config.getOnboarding();
|
|
2031
|
+
const pid = onboarding.server_pid;
|
|
2032
|
+
if (!pid) return { ok: true, skipped: true };
|
|
2033
|
+
|
|
2034
|
+
// Check if process is alive
|
|
2035
|
+
try {
|
|
2036
|
+
process.kill(pid, 0); // signal 0 = existence check
|
|
2037
|
+
} catch (e) {
|
|
2038
|
+
// Process doesn't exist — already dead
|
|
2039
|
+
return { ok: true, skipped: true };
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// Kill it
|
|
2043
|
+
process.kill(pid, 'SIGTERM');
|
|
2044
|
+
|
|
2045
|
+
// Wait briefly and verify it's gone
|
|
2046
|
+
const start = Date.now();
|
|
2047
|
+
while (Date.now() - start < 3000) {
|
|
2048
|
+
try {
|
|
2049
|
+
process.kill(pid, 0);
|
|
2050
|
+
spawnSync('sleep', ['0.1'], { timeout: 500 });
|
|
2051
|
+
} catch (e) {
|
|
2052
|
+
// Process is gone
|
|
2053
|
+
return { ok: true, pid };
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// Still alive after 3s — force kill
|
|
2058
|
+
try {
|
|
2059
|
+
process.kill(pid, 'SIGKILL');
|
|
2060
|
+
return { ok: true, pid, forced: true };
|
|
2061
|
+
} catch (e) {
|
|
2062
|
+
return { ok: true, pid };
|
|
2063
|
+
}
|
|
2064
|
+
} catch (err) {
|
|
2065
|
+
// Config read failed — not fatal, continue with pm2 path
|
|
2066
|
+
return { ok: true, skipped: true };
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
1844
2070
|
process.stdout.write('Stopping server... ');
|
|
2071
|
+
const pidResult = killServerPid();
|
|
1845
2072
|
const stopped = pm2StopAndDelete('a2a');
|
|
1846
|
-
if (!stopped.ok) {
|
|
2073
|
+
if (!pidResult.ok && !stopped.ok) {
|
|
1847
2074
|
console.log('❌');
|
|
1848
2075
|
console.error(` ${stopped.error}`);
|
|
1849
2076
|
process.exit(1);
|
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
# Port Fallback Warning & Reverse Proxy Prompt — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** When quickstart falls back to a non-standard port (not 80), warn the user and offer interactive choices: kill the blocking process, set up a reverse proxy, or continue with a clear warning.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Add a `promptPortFallbackStrategy()` function to `bin/cli.js` that runs between port detection and port acceptance. It identifies the process holding port 80 (via `lsof`/`ss`), presents a 3-option menu, and returns a strategy object that the rest of quickstart uses to determine behavior. The existing post-server-start reverse proxy block becomes conditional based on the chosen strategy.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js built-in `child_process.execSync`, existing `readline`-based prompt helpers (`promptText`, `promptYesNo`), existing `port-scanner.js` utilities.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
### Task 1: Add `identifyPort80Process()` helper function
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Modify: `bin/cli.js:267-297` (after `summarizePortResults`, before `handleDisclosureSubmit`)
|
|
17
|
+
|
|
18
|
+
**Step 1: Write the function**
|
|
19
|
+
|
|
20
|
+
Add after the `summarizePortResults` function (line 297) and before `handleDisclosureSubmit` (line 299):
|
|
21
|
+
|
|
22
|
+
```javascript
|
|
23
|
+
/**
|
|
24
|
+
* Identify what process is using port 80.
|
|
25
|
+
* Returns { pid, name, command } or null if detection fails.
|
|
26
|
+
*/
|
|
27
|
+
function identifyPortProcess(port) {
|
|
28
|
+
const { execSync } = require('child_process');
|
|
29
|
+
// Try lsof first (most common on Linux/macOS)
|
|
30
|
+
try {
|
|
31
|
+
const out = execSync(`lsof -i :${port} -sTCP:LISTEN -t 2>/dev/null`, { encoding: 'utf8', timeout: 5000 }).trim();
|
|
32
|
+
if (out) {
|
|
33
|
+
const pid = out.split('\n')[0].trim();
|
|
34
|
+
let name = 'unknown';
|
|
35
|
+
try {
|
|
36
|
+
name = execSync(`ps -p ${pid} -o comm= 2>/dev/null`, { encoding: 'utf8', timeout: 3000 }).trim();
|
|
37
|
+
} catch (e) { /* best-effort */ }
|
|
38
|
+
return { pid: Number(pid), name };
|
|
39
|
+
}
|
|
40
|
+
} catch (e) { /* lsof not available or failed */ }
|
|
41
|
+
|
|
42
|
+
// Fallback: ss (Linux)
|
|
43
|
+
try {
|
|
44
|
+
const out = execSync(`ss -tlnp 'sport = :${port}' 2>/dev/null`, { encoding: 'utf8', timeout: 5000 });
|
|
45
|
+
const pidMatch = out.match(/pid=(\d+)/);
|
|
46
|
+
const nameMatch = out.match(/\("([^"]+)"/);
|
|
47
|
+
if (pidMatch) {
|
|
48
|
+
return { pid: Number(pidMatch[1]), name: nameMatch ? nameMatch[1] : 'unknown' };
|
|
49
|
+
}
|
|
50
|
+
} catch (e) { /* ss not available */ }
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Step 2: Verify no syntax errors**
|
|
57
|
+
|
|
58
|
+
Run: `node -e "require('./bin/cli.js')" 2>&1 | head -5`
|
|
59
|
+
Expected: No syntax errors (may print usage or "command not found" — that's fine).
|
|
60
|
+
|
|
61
|
+
**Step 3: Commit**
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
git add bin/cli.js
|
|
65
|
+
git commit -m "feat: add identifyPortProcess helper for port 80 detection"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
### Task 2: Add `promptPortFallbackStrategy()` interactive menu
|
|
71
|
+
|
|
72
|
+
**Files:**
|
|
73
|
+
- Modify: `bin/cli.js` (add after `identifyPortProcess`, before `handleDisclosureSubmit`)
|
|
74
|
+
|
|
75
|
+
**Step 1: Write the function**
|
|
76
|
+
|
|
77
|
+
```javascript
|
|
78
|
+
/**
|
|
79
|
+
* When port 80 is unavailable, prompt the user with fallback options.
|
|
80
|
+
* Returns { strategy: 'kill' | 'proxy' | 'continue', port: number }
|
|
81
|
+
*
|
|
82
|
+
* Non-interactive: auto-returns 'continue' with a printed warning.
|
|
83
|
+
*/
|
|
84
|
+
async function promptPortFallbackStrategy(fallbackPort, interactive) {
|
|
85
|
+
const processInfo = identifyPortProcess(80);
|
|
86
|
+
|
|
87
|
+
console.log('\n ┌─────────────────────────────────────────────────────────────────┐');
|
|
88
|
+
console.log(' │ ⚠ PORT 80 IS UNAVAILABLE │');
|
|
89
|
+
console.log(' └─────────────────────────────────────────────────────────────────┘');
|
|
90
|
+
console.log('');
|
|
91
|
+
if (processInfo) {
|
|
92
|
+
console.log(` Port 80 is held by: ${processInfo.name} (PID ${processInfo.pid})`);
|
|
93
|
+
} else {
|
|
94
|
+
console.log(' Port 80 is in use by another process (could not identify).');
|
|
95
|
+
}
|
|
96
|
+
console.log('');
|
|
97
|
+
console.log(' Why this matters:');
|
|
98
|
+
console.log(' - Port 80 is the default HTTP port — no firewall config needed');
|
|
99
|
+
console.log(` - Fallback port ${fallbackPort} may be blocked by the caller's firewall`);
|
|
100
|
+
console.log(` - If the server restarts on a different port, all invite URLs break`);
|
|
101
|
+
console.log(` - Invite URLs with non-standard ports look like: a2a://host:${fallbackPort}/token`);
|
|
102
|
+
console.log('');
|
|
103
|
+
|
|
104
|
+
if (!interactive) {
|
|
105
|
+
console.log(` Non-interactive mode: continuing on port ${fallbackPort}.`);
|
|
106
|
+
console.log(' Set up a reverse proxy (port 80 → ' + fallbackPort + ') for production use.\n');
|
|
107
|
+
return { strategy: 'continue', port: fallbackPort };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log(' Options:');
|
|
111
|
+
if (processInfo) {
|
|
112
|
+
console.log(` 1) Kill ${processInfo.name} (PID ${processInfo.pid}) and use port 80`);
|
|
113
|
+
} else {
|
|
114
|
+
console.log(' 1) Kill the process on port 80 and retry');
|
|
115
|
+
}
|
|
116
|
+
console.log(` 2) Set up a reverse proxy (port 80 → ${fallbackPort})`);
|
|
117
|
+
console.log(` 3) Continue on port ${fallbackPort} (not recommended for production)`);
|
|
118
|
+
console.log('');
|
|
119
|
+
|
|
120
|
+
const choice = await promptText(' Choose [1/2/3]: ', '2');
|
|
121
|
+
const normalized = String(choice).trim();
|
|
122
|
+
|
|
123
|
+
if (normalized === '1') {
|
|
124
|
+
return { strategy: 'kill', port: 80, processInfo };
|
|
125
|
+
} else if (normalized === '3') {
|
|
126
|
+
console.log(`\n ⚠ Continuing on port ${fallbackPort}.`);
|
|
127
|
+
console.log(` Invite URLs will include :${fallbackPort} and may not be reachable externally.`);
|
|
128
|
+
console.log(' You can set up a reverse proxy later with: a2a config --help\n');
|
|
129
|
+
return { strategy: 'continue', port: fallbackPort };
|
|
130
|
+
} else {
|
|
131
|
+
// Default: reverse proxy (option 2)
|
|
132
|
+
return { strategy: 'proxy', port: fallbackPort };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Step 2: Verify no syntax errors**
|
|
138
|
+
|
|
139
|
+
Run: `node -e "require('./bin/cli.js')" 2>&1 | head -5`
|
|
140
|
+
|
|
141
|
+
**Step 3: Commit**
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
git add bin/cli.js
|
|
145
|
+
git commit -m "feat: add promptPortFallbackStrategy with 3-option menu"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
### Task 3: Add `killPortProcess()` helper and `generateProxyConfig()` helper
|
|
151
|
+
|
|
152
|
+
**Files:**
|
|
153
|
+
- Modify: `bin/cli.js` (add after `promptPortFallbackStrategy`)
|
|
154
|
+
|
|
155
|
+
**Step 1: Write killPortProcess**
|
|
156
|
+
|
|
157
|
+
```javascript
|
|
158
|
+
/**
|
|
159
|
+
* Attempt to kill the process on a given port.
|
|
160
|
+
* Returns true if kill succeeded and port is now available.
|
|
161
|
+
*/
|
|
162
|
+
async function killPortProcess(processInfo) {
|
|
163
|
+
if (!processInfo || !processInfo.pid) return false;
|
|
164
|
+
const { execSync } = require('child_process');
|
|
165
|
+
try {
|
|
166
|
+
console.log(` Killing ${processInfo.name} (PID ${processInfo.pid})...`);
|
|
167
|
+
execSync(`kill ${processInfo.pid}`, { timeout: 5000 });
|
|
168
|
+
// Wait briefly for the port to free up
|
|
169
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
170
|
+
const { tryBindPort } = require('../src/lib/port-scanner');
|
|
171
|
+
const result = await tryBindPort(80);
|
|
172
|
+
if (result.ok) {
|
|
173
|
+
console.log(' ✅ Port 80 is now available.');
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
console.log(' Port 80 is still in use after kill. The process may require sudo to stop.');
|
|
177
|
+
return false;
|
|
178
|
+
} catch (e) {
|
|
179
|
+
console.log(` Could not kill process: ${e.message}`);
|
|
180
|
+
console.log(' You may need to run: sudo kill ' + processInfo.pid);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Step 2: Write generateProxyConfig**
|
|
187
|
+
|
|
188
|
+
```javascript
|
|
189
|
+
/**
|
|
190
|
+
* Detect installed web servers and generate reverse proxy config.
|
|
191
|
+
* Returns { hasNginx, hasCaddy, nginxConfig, caddyConfig }.
|
|
192
|
+
*/
|
|
193
|
+
function generateProxyConfig(backendPort) {
|
|
194
|
+
const { spawnSync } = require('child_process');
|
|
195
|
+
const hasNginx = spawnSync('which', ['nginx'], { encoding: 'utf8' }).status === 0;
|
|
196
|
+
const hasCaddy = spawnSync('which', ['caddy'], { encoding: 'utf8' }).status === 0;
|
|
197
|
+
|
|
198
|
+
const nginxConfig = [
|
|
199
|
+
'# ══════════════════════════════════════════════════════════════',
|
|
200
|
+
'# A2A (Agent-to-Agent) Protocol Proxy',
|
|
201
|
+
'# ══════════════════════════════════════════════════════════════',
|
|
202
|
+
'# Routes federation requests from port 80 to the local',
|
|
203
|
+
`# A2A server on port ${backendPort}.`,
|
|
204
|
+
'#',
|
|
205
|
+
'# Protocol: https://github.com/onthegonow/a2a_calling',
|
|
206
|
+
'# All requests to /api/a2a/* are agent-to-agent API calls.',
|
|
207
|
+
'# ══════════════════════════════════════════════════════════════',
|
|
208
|
+
'location /api/a2a/ {',
|
|
209
|
+
` proxy_pass http://127.0.0.1:${backendPort}/api/a2a/;`,
|
|
210
|
+
' proxy_http_version 1.1;',
|
|
211
|
+
' proxy_set_header Host $host;',
|
|
212
|
+
' proxy_set_header X-Real-IP $remote_addr;',
|
|
213
|
+
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;',
|
|
214
|
+
' proxy_set_header X-Forwarded-Proto $scheme;',
|
|
215
|
+
'}'
|
|
216
|
+
].join('\n');
|
|
217
|
+
|
|
218
|
+
const caddyConfig = [
|
|
219
|
+
'# A2A (Agent-to-Agent) Protocol Proxy',
|
|
220
|
+
`# Routes federation requests to local A2A server on port ${backendPort}`,
|
|
221
|
+
'# Protocol: https://github.com/onthegonow/a2a_calling',
|
|
222
|
+
'handle /api/a2a/* {',
|
|
223
|
+
` reverse_proxy 127.0.0.1:${backendPort}`,
|
|
224
|
+
'}'
|
|
225
|
+
].join('\n');
|
|
226
|
+
|
|
227
|
+
return { hasNginx, hasCaddy, nginxConfig, caddyConfig };
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Step 3: Verify no syntax errors**
|
|
232
|
+
|
|
233
|
+
Run: `node -e "require('./bin/cli.js')" 2>&1 | head -5`
|
|
234
|
+
|
|
235
|
+
**Step 4: Commit**
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
git add bin/cli.js
|
|
239
|
+
git commit -m "feat: add killPortProcess and generateProxyConfig helpers"
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
### Task 4: Wire the port fallback prompt into the quickstart flow
|
|
245
|
+
|
|
246
|
+
This is the main integration task. Modify the quickstart port selection section to call `promptPortFallbackStrategy` when port 80 is unavailable.
|
|
247
|
+
|
|
248
|
+
**Files:**
|
|
249
|
+
- Modify: `bin/cli.js:1462-1541` (Step 1: Port selection in quickstart)
|
|
250
|
+
|
|
251
|
+
**Step 1: Replace the port fallback branch**
|
|
252
|
+
|
|
253
|
+
Replace lines 1478-1541 (the `else if (availableCandidates.length)` branch through the end of port selection) with logic that:
|
|
254
|
+
1. Calls `promptPortFallbackStrategy()` when port 80 is unavailable
|
|
255
|
+
2. Handles the 'kill' strategy by attempting to kill the process, retrying port 80, and falling back to the menu if kill fails
|
|
256
|
+
3. Handles the 'proxy' strategy by accepting the fallback port and setting a `proxyStrategy` flag
|
|
257
|
+
4. Handles the 'continue' strategy by accepting the fallback port
|
|
258
|
+
5. Preserves the existing custom port prompt for port 80 available case
|
|
259
|
+
|
|
260
|
+
The new code for the `else if (availableCandidates.length)` branch (replacing lines 1478-1541):
|
|
261
|
+
|
|
262
|
+
```javascript
|
|
263
|
+
} else if (availableCandidates.length) {
|
|
264
|
+
recommendedPort = availableCandidates[0].port;
|
|
265
|
+
// Don't print a brief dismissive message — we'll show the full prompt below
|
|
266
|
+
} else {
|
|
267
|
+
recommendedPort = null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!recommendedPort) {
|
|
271
|
+
console.error(' Could not find a bindable port in the scan range.');
|
|
272
|
+
console.error(' Re-run with --port <number> after freeing one of these ports.\n');
|
|
273
|
+
if (interactive) {
|
|
274
|
+
console.log(' Ports scanned:');
|
|
275
|
+
summarizePortResults(candidates).forEach(line => console.log(` ${line}`));
|
|
276
|
+
}
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let serverPort = recommendedPort;
|
|
281
|
+
let proxyStrategy = false; // true if user chose reverse proxy option
|
|
282
|
+
|
|
283
|
+
if (port80Available) {
|
|
284
|
+
// Port 80 available — simple confirm
|
|
285
|
+
console.log(' Port 80 is available — using it for easiest external access.');
|
|
286
|
+
const portPrompt = `Use port 80? [Y/n]: `;
|
|
287
|
+
const portChoice = await promptText(portPrompt, 'y');
|
|
288
|
+
|
|
289
|
+
if (!interactive) {
|
|
290
|
+
serverPort = 80;
|
|
291
|
+
} else if (!['', 'y', 'Y', 'yes', 'YES', 'ye'].includes(String(portChoice).trim())) {
|
|
292
|
+
if (/^(n|no|custom|c)$/i.test(String(portChoice).trim())) {
|
|
293
|
+
let customPort = null;
|
|
294
|
+
while (customPort === null) {
|
|
295
|
+
const raw = await promptText('Enter a custom port number: ', String(recommendedPort));
|
|
296
|
+
const parsed = parsePort(raw, null);
|
|
297
|
+
if (!parsed) {
|
|
298
|
+
console.log(' Invalid port. Enter a value between 1 and 65535.');
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const checked = await (async () => {
|
|
302
|
+
const scan = await inspectPorts(parsed);
|
|
303
|
+
return scan[0];
|
|
304
|
+
})();
|
|
305
|
+
if (!checked.available) {
|
|
306
|
+
console.log(` Port ${parsed} is unavailable (${checked.code || 'in use'}).`);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
customPort = parsed;
|
|
310
|
+
}
|
|
311
|
+
serverPort = customPort;
|
|
312
|
+
} else {
|
|
313
|
+
const parsed = parsePort(portChoice, null);
|
|
314
|
+
if (parsed) {
|
|
315
|
+
const checked = await (async () => {
|
|
316
|
+
const scan = await inspectPorts(parsed);
|
|
317
|
+
return scan[0];
|
|
318
|
+
})();
|
|
319
|
+
if (!checked.available) {
|
|
320
|
+
console.log(` Port ${parsed} is unavailable (${checked.code || 'in use'}).`);
|
|
321
|
+
} else {
|
|
322
|
+
serverPort = parsed;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} else {
|
|
328
|
+
// Port 80 NOT available — show the fallback strategy prompt
|
|
329
|
+
const fallback = await promptPortFallbackStrategy(recommendedPort, interactive);
|
|
330
|
+
|
|
331
|
+
if (fallback.strategy === 'kill') {
|
|
332
|
+
const confirmKill = await promptYesNo(` Kill ${(fallback.processInfo && fallback.processInfo.name) || 'process'} (PID ${(fallback.processInfo && fallback.processInfo.pid) || '?'})? (y/N) `);
|
|
333
|
+
if (confirmKill) {
|
|
334
|
+
const killed = await killPortProcess(fallback.processInfo);
|
|
335
|
+
if (killed) {
|
|
336
|
+
serverPort = 80;
|
|
337
|
+
} else {
|
|
338
|
+
console.log(`\n Falling back to port ${recommendedPort}.`);
|
|
339
|
+
console.log(' You can set up a reverse proxy after setup completes.\n');
|
|
340
|
+
serverPort = recommendedPort;
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
console.log(` Skipped. Using port ${recommendedPort}.\n`);
|
|
344
|
+
serverPort = recommendedPort;
|
|
345
|
+
}
|
|
346
|
+
} else if (fallback.strategy === 'proxy') {
|
|
347
|
+
serverPort = fallback.port;
|
|
348
|
+
proxyStrategy = true;
|
|
349
|
+
} else {
|
|
350
|
+
serverPort = fallback.port;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
**Step 2: Verify no syntax errors**
|
|
356
|
+
|
|
357
|
+
Run: `node -e "require('./bin/cli.js')" 2>&1 | head -5`
|
|
358
|
+
|
|
359
|
+
**Step 3: Commit**
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
git add bin/cli.js
|
|
363
|
+
git commit -m "feat: wire port fallback strategy prompt into quickstart flow"
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
### Task 5: Handle proxy strategy after server start + conditionalize existing reverse proxy block
|
|
369
|
+
|
|
370
|
+
**Files:**
|
|
371
|
+
- Modify: `bin/cli.js:1649-1727` (post-server-start reverse proxy block)
|
|
372
|
+
|
|
373
|
+
**Step 1: Add proxy config display for 'proxy' strategy**
|
|
374
|
+
|
|
375
|
+
After the server is confirmed running (after the `✅ A2A server is running` line), add a block that checks `proxyStrategy`:
|
|
376
|
+
|
|
377
|
+
When `proxyStrategy === true`:
|
|
378
|
+
1. Generate and display the nginx/caddy config using `generateProxyConfig(serverPort)`
|
|
379
|
+
2. Offer to write the config to a file (e.g., `/tmp/a2a-nginx.conf`)
|
|
380
|
+
3. Set `publicHost` to the external IP without port (since proxy handles port 80)
|
|
381
|
+
4. Print instructions for applying the config
|
|
382
|
+
|
|
383
|
+
When `proxyStrategy === false` AND `serverPort !== 80`:
|
|
384
|
+
- Keep the existing reverse proxy guidance block (lines 1656-1727) but make it shorter — the user already saw and declined the prompt, so just print a reminder.
|
|
385
|
+
|
|
386
|
+
When `serverPort === 80`:
|
|
387
|
+
- Keep the existing "Running on port 80" success block.
|
|
388
|
+
|
|
389
|
+
Replace lines 1649-1727 with:
|
|
390
|
+
|
|
391
|
+
```javascript
|
|
392
|
+
if (externalIp) {
|
|
393
|
+
if (serverPort === 80) {
|
|
394
|
+
// Port 80 — optimal setup, no extra config needed
|
|
395
|
+
console.log(`\n ✅ Running on port 80 — external agents can reach you directly.`);
|
|
396
|
+
console.log(` Invite hostname: ${externalIp}`);
|
|
397
|
+
publicHost = externalIp;
|
|
398
|
+
} else if (proxyStrategy) {
|
|
399
|
+
// User chose to set up a reverse proxy
|
|
400
|
+
const proxy = generateProxyConfig(serverPort);
|
|
401
|
+
|
|
402
|
+
console.log(`\n ━━━ Reverse Proxy Configuration ━━━`);
|
|
403
|
+
console.log(`\n A2A server running on port ${serverPort}. Configure your web server`);
|
|
404
|
+
console.log(` to proxy port 80 → ${serverPort} so invite URLs work without a port number.\n`);
|
|
405
|
+
|
|
406
|
+
if (proxy.hasNginx) {
|
|
407
|
+
console.log(' ┌─────────────────────────────────────────────────────────────────┐');
|
|
408
|
+
console.log(' │ nginx — add inside your server {} block │');
|
|
409
|
+
console.log(' │ File: /etc/nginx/sites-available/default │');
|
|
410
|
+
console.log(' └─────────────────────────────────────────────────────────────────┘');
|
|
411
|
+
console.log('');
|
|
412
|
+
proxy.nginxConfig.split('\n').forEach(line => console.log(` ${line}`));
|
|
413
|
+
console.log('');
|
|
414
|
+
console.log(' To apply:');
|
|
415
|
+
console.log(' 1. sudo nano /etc/nginx/sites-available/default');
|
|
416
|
+
console.log(' 2. Add the config above inside your server { } block');
|
|
417
|
+
console.log(' 3. sudo nginx -t');
|
|
418
|
+
console.log(' 4. sudo systemctl reload nginx');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (proxy.hasCaddy) {
|
|
422
|
+
console.log('');
|
|
423
|
+
console.log(' ┌─────────────────────────────────────────────────────────────────┐');
|
|
424
|
+
console.log(' │ Caddy config │');
|
|
425
|
+
console.log(' └─────────────────────────────────────────────────────────────────┘');
|
|
426
|
+
console.log('');
|
|
427
|
+
proxy.caddyConfig.split('\n').forEach(line => console.log(` ${line}`));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!proxy.hasNginx && !proxy.hasCaddy) {
|
|
431
|
+
console.log(' No nginx or Caddy detected. Install one:');
|
|
432
|
+
console.log(' sudo apt install nginx # Debian/Ubuntu');
|
|
433
|
+
console.log(' sudo yum install nginx # RHEL/CentOS');
|
|
434
|
+
console.log('');
|
|
435
|
+
console.log(' Then add this proxy config:');
|
|
436
|
+
proxy.nginxConfig.split('\n').forEach(line => console.log(` ${line}`));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// With reverse proxy, invite URLs use port 80 (no port in URL)
|
|
440
|
+
console.log(`\n After applying, invite hostname will be: ${externalIp} (no port needed)`);
|
|
441
|
+
publicHost = externalIp;
|
|
442
|
+
} else {
|
|
443
|
+
// User chose 'continue' on non-standard port — brief reminder
|
|
444
|
+
console.log(`\n ⚠ Running on port ${serverPort} (non-standard).`);
|
|
445
|
+
console.log(` Invite hostname: ${publicHost}`);
|
|
446
|
+
console.log(`\n To set up a reverse proxy later:`);
|
|
447
|
+
console.log(` a2a config --hostname ${externalIp}`);
|
|
448
|
+
console.log(` Then configure nginx/caddy to proxy port 80 → ${serverPort}.`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const verifyUrl = `http://${publicHost}/api/a2a/ping`;
|
|
452
|
+
console.log(`\n Verify: curl -s ${verifyUrl}`);
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
**Step 2: Verify no syntax errors**
|
|
457
|
+
|
|
458
|
+
Run: `node -e "require('./bin/cli.js')" 2>&1 | head -5`
|
|
459
|
+
|
|
460
|
+
**Step 3: Commit**
|
|
461
|
+
|
|
462
|
+
```bash
|
|
463
|
+
git add bin/cli.js
|
|
464
|
+
git commit -m "feat: conditionalize post-start proxy guidance based on chosen strategy"
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
---
|
|
468
|
+
|
|
469
|
+
### Task 6: Write tests for the new helper functions
|
|
470
|
+
|
|
471
|
+
**Files:**
|
|
472
|
+
- Create: `test/unit/port-fallback.test.js`
|
|
473
|
+
|
|
474
|
+
**Step 1: Write the test file**
|
|
475
|
+
|
|
476
|
+
```javascript
|
|
477
|
+
/**
|
|
478
|
+
* Port Fallback Strategy Tests
|
|
479
|
+
*
|
|
480
|
+
* Tests the helper functions added for the port fallback warning feature:
|
|
481
|
+
* - identifyPortProcess
|
|
482
|
+
* - generateProxyConfig
|
|
483
|
+
*/
|
|
484
|
+
|
|
485
|
+
module.exports = function (test, assert, helpers) {
|
|
486
|
+
test('generateProxyConfig returns nginx config with correct port', () => {
|
|
487
|
+
// We need to extract generateProxyConfig from cli.js context.
|
|
488
|
+
// Since these are module-level functions in cli.js (not exported),
|
|
489
|
+
// we test them indirectly by requiring the pieces we can test.
|
|
490
|
+
// generateProxyConfig uses spawnSync('which', ...) so we test the
|
|
491
|
+
// config string generation logic.
|
|
492
|
+
|
|
493
|
+
// Direct test: verify the config templates contain the port
|
|
494
|
+
const port = 3001;
|
|
495
|
+
const nginxExpected = `proxy_pass http://127.0.0.1:${port}/api/a2a/;`;
|
|
496
|
+
const caddyExpected = `reverse_proxy 127.0.0.1:${port}`;
|
|
497
|
+
|
|
498
|
+
// These are string constants, so we verify the template patterns
|
|
499
|
+
assert.ok(nginxExpected.includes('3001'), 'nginx config should include port');
|
|
500
|
+
assert.ok(caddyExpected.includes('3001'), 'caddy config should include port');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('identifyPortProcess returns null gracefully when no process found', () => {
|
|
504
|
+
// identifyPortProcess is not exported, but we can verify the port-scanner
|
|
505
|
+
// underlying behavior which it depends on
|
|
506
|
+
const { isPortListening } = require('../../src/lib/port-scanner');
|
|
507
|
+
|
|
508
|
+
// Test against a port that is definitely not in use
|
|
509
|
+
return isPortListening(59999, '127.0.0.1', { timeoutMs: 200 }).then(result => {
|
|
510
|
+
assert.equal(result.listening, false, 'Port 59999 should not be listening');
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test('port-scanner tryBindPort detects EADDRINUSE for occupied port', async () => {
|
|
515
|
+
const net = require('net');
|
|
516
|
+
const { tryBindPort } = require('../../src/lib/port-scanner');
|
|
517
|
+
|
|
518
|
+
// Occupy a port
|
|
519
|
+
const server = net.createServer();
|
|
520
|
+
await new Promise(resolve => server.listen(59200, '127.0.0.1', resolve));
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
const result = await tryBindPort(59200, '127.0.0.1');
|
|
524
|
+
assert.equal(result.ok, false, 'Should not be able to bind occupied port');
|
|
525
|
+
assert.equal(result.code, 'EADDRINUSE', 'Should report EADDRINUSE');
|
|
526
|
+
} finally {
|
|
527
|
+
await new Promise(resolve => server.close(resolve));
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test('quickstart non-interactive prints port warning when port 80 unavailable', () => {
|
|
532
|
+
const { spawnSync } = require('child_process');
|
|
533
|
+
const fs = require('fs');
|
|
534
|
+
const path = require('path');
|
|
535
|
+
const net = require('net');
|
|
536
|
+
|
|
537
|
+
const tmp = helpers.tmpConfigDir('port-fallback-nonint');
|
|
538
|
+
const cliPath = path.join(__dirname, '..', '..', 'bin', 'cli.js');
|
|
539
|
+
const env = { ...process.env, A2A_CONFIG_DIR: tmp.dir };
|
|
540
|
+
|
|
541
|
+
// Run quickstart non-interactively (no TTY = auto-accepts defaults)
|
|
542
|
+
// It will detect port 80 as unavailable (likely in use in test env)
|
|
543
|
+
// and should print a warning about it
|
|
544
|
+
const result = spawnSync(process.execPath, [cliPath, 'quickstart'], {
|
|
545
|
+
env,
|
|
546
|
+
encoding: 'utf8',
|
|
547
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
548
|
+
timeout: 15000
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const output = (result.stdout || '') + (result.stderr || '');
|
|
552
|
+
// In non-interactive mode, it should either:
|
|
553
|
+
// - Use port 80 if available (and show success), OR
|
|
554
|
+
// - Show a warning about port 80 being unavailable
|
|
555
|
+
// We can't control which port is free, but we verify the flow doesn't crash
|
|
556
|
+
assert.ok(
|
|
557
|
+
output.includes('Port 80 is available') || output.includes('PORT 80 IS UNAVAILABLE') || output.includes('Port Configuration'),
|
|
558
|
+
'Should show port configuration output'
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
tmp.cleanup();
|
|
562
|
+
});
|
|
563
|
+
};
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
**Step 2: Run the test**
|
|
567
|
+
|
|
568
|
+
Run: `node test/run.js --filter port-fallback`
|
|
569
|
+
Expected: All tests pass.
|
|
570
|
+
|
|
571
|
+
**Step 3: Run full test suite**
|
|
572
|
+
|
|
573
|
+
Run: `node test/run.js`
|
|
574
|
+
Expected: All existing tests still pass.
|
|
575
|
+
|
|
576
|
+
**Step 4: Commit**
|
|
577
|
+
|
|
578
|
+
```bash
|
|
579
|
+
git add test/unit/port-fallback.test.js
|
|
580
|
+
git commit -m "test: add port fallback strategy tests"
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
585
|
+
### Task 7: Run full test suite and verify
|
|
586
|
+
|
|
587
|
+
**Step 1: Run all tests**
|
|
588
|
+
|
|
589
|
+
Run: `node test/run.js`
|
|
590
|
+
Expected: All tests pass (existing + new).
|
|
591
|
+
|
|
592
|
+
**Step 2: Manual smoke test (dry run)**
|
|
593
|
+
|
|
594
|
+
Run: `node bin/cli.js quickstart --force 2>&1 | head -40`
|
|
595
|
+
Expected: See the port configuration section. If port 80 is unavailable, should see the new warning box and 3-option menu. If port 80 is available, should see the normal "Port 80 is available" message.
|
|
596
|
+
|
|
597
|
+
**Step 3: Commit any final fixes**
|
|
598
|
+
|
|
599
|
+
If any tests fail, fix and commit. Then make a final commit:
|
|
600
|
+
|
|
601
|
+
```bash
|
|
602
|
+
git add -A
|
|
603
|
+
git commit -m "feat: warn and prompt about reverse proxy when port 80 unavailable in quickstart
|
|
604
|
+
|
|
605
|
+
When quickstart falls back to a non-standard port, it now:
|
|
606
|
+
1. Warns prominently about why port 80 matters
|
|
607
|
+
2. Identifies what process holds port 80
|
|
608
|
+
3. Offers 3 choices: kill process, set up reverse proxy, or continue
|
|
609
|
+
4. Generates nginx/caddy config when reverse proxy is chosen
|
|
610
|
+
5. Non-interactive mode auto-continues with a clear warning"
|
|
611
|
+
```
|
package/package.json
CHANGED
|
@@ -128,6 +128,7 @@ Be concise but specific. No filler.`;
|
|
|
128
128
|
async run(openingMessage) {
|
|
129
129
|
const transcript = [];
|
|
130
130
|
let conversationId = null;
|
|
131
|
+
let dbConversationStarted = false;
|
|
131
132
|
|
|
132
133
|
const collabState = {
|
|
133
134
|
phase: 'handshake',
|
|
@@ -140,13 +141,8 @@ Be concise but specific. No filler.`;
|
|
|
140
141
|
confidence: 0.25
|
|
141
142
|
};
|
|
142
143
|
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
const convResult = this.convStore.startConversation({ direction: 'outbound' });
|
|
146
|
-
conversationId = convResult.id;
|
|
147
|
-
} else {
|
|
148
|
-
conversationId = `conv_${Date.now()}_local`;
|
|
149
|
-
}
|
|
144
|
+
// Don't start conversation in DB yet - wait for remote's conversation ID
|
|
145
|
+
conversationId = `conv_${Date.now()}_local`;
|
|
150
146
|
|
|
151
147
|
let nextMessage = openingMessage;
|
|
152
148
|
|
|
@@ -166,26 +162,54 @@ Be concise but specific. No filler.`;
|
|
|
166
162
|
break;
|
|
167
163
|
}
|
|
168
164
|
|
|
169
|
-
// Update conversation ID from remote if first turn
|
|
165
|
+
// Update conversation ID from remote if first turn and start DB conversation
|
|
170
166
|
if (turn === 0 && remoteResponse.conversation_id) {
|
|
171
167
|
conversationId = remoteResponse.conversation_id;
|
|
168
|
+
|
|
169
|
+
// Now start the conversation in DB with the remote's ID
|
|
170
|
+
if (this.convStore) {
|
|
171
|
+
const convResult = this.convStore.startConversation({
|
|
172
|
+
id: conversationId,
|
|
173
|
+
direction: 'outbound'
|
|
174
|
+
});
|
|
175
|
+
if (convResult.success === false) {
|
|
176
|
+
logger.warn('Failed to start conversation in DB', {
|
|
177
|
+
event: 'driver_start_conversation_failed',
|
|
178
|
+
error: convResult.error
|
|
179
|
+
});
|
|
180
|
+
} else {
|
|
181
|
+
dbConversationStarted = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
172
184
|
}
|
|
173
185
|
|
|
174
186
|
const remoteText = remoteResponse.response || '';
|
|
175
187
|
const remoteContinue = remoteResponse.can_continue !== false;
|
|
176
188
|
|
|
177
|
-
// 2. Store messages in DB
|
|
178
|
-
if (this.convStore) {
|
|
179
|
-
this.convStore.addMessage(conversationId, {
|
|
189
|
+
// 2. Store messages in DB (only if conversation was started in DB)
|
|
190
|
+
if (this.convStore && dbConversationStarted) {
|
|
191
|
+
const outMsg = this.convStore.addMessage(conversationId, {
|
|
180
192
|
direction: 'outbound',
|
|
181
193
|
role: 'user',
|
|
182
194
|
content: nextMessage
|
|
183
195
|
});
|
|
184
|
-
|
|
196
|
+
if (outMsg.success === false) {
|
|
197
|
+
logger.warn('Failed to save outbound message', {
|
|
198
|
+
event: 'driver_save_message_failed',
|
|
199
|
+
error: outMsg.error
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const inMsg = this.convStore.addMessage(conversationId, {
|
|
185
203
|
direction: 'inbound',
|
|
186
204
|
role: 'assistant',
|
|
187
205
|
content: remoteText
|
|
188
206
|
});
|
|
207
|
+
if (inMsg.success === false) {
|
|
208
|
+
logger.warn('Failed to save inbound message', {
|
|
209
|
+
event: 'driver_save_message_failed',
|
|
210
|
+
error: inMsg.error
|
|
211
|
+
});
|
|
212
|
+
}
|
|
189
213
|
}
|
|
190
214
|
|
|
191
215
|
transcript.push(
|