limbo-ai 1.17.0 → 1.18.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/cli.js +162 -7
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -152,7 +152,7 @@ function composeContent() {
|
|
|
152
152
|
- /tmp:size=100M,noexec,nosuid,nodev
|
|
153
153
|
- /home/limbo/.npm:size=50M,noexec,nosuid,nodev
|
|
154
154
|
ports:
|
|
155
|
-
- "127.0.0.1:${PORT}:${PORT}"
|
|
155
|
+
- "${isServerEnvironment() ? '0.0.0.0' : '127.0.0.1'}:${PORT}:${PORT}"
|
|
156
156
|
volumes:
|
|
157
157
|
- limbo-data:/data
|
|
158
158
|
- ${VAULT_DIR}:/data/vault
|
|
@@ -212,7 +212,7 @@ function composeContentHardened() {
|
|
|
212
212
|
- /tmp:size=100M,noexec,nosuid,nodev
|
|
213
213
|
- /home/limbo/.npm:size=50M,noexec,nosuid,nodev
|
|
214
214
|
ports:
|
|
215
|
-
- "127.0.0.1:${PORT}:${PORT}"
|
|
215
|
+
- "${isServerEnvironment() ? '0.0.0.0' : '127.0.0.1'}:${PORT}:${PORT}"
|
|
216
216
|
volumes:
|
|
217
217
|
- limbo-data:/data
|
|
218
218
|
- ${VAULT_DIR}:/data/vault
|
|
@@ -1001,6 +1001,140 @@ function ensureVolumePermissions() {
|
|
|
1001
1001
|
], { stdio: 'pipe' });
|
|
1002
1002
|
}
|
|
1003
1003
|
|
|
1004
|
+
// ─── Server detection & Cloudflare tunnel for remote wizard access ──────────
|
|
1005
|
+
|
|
1006
|
+
function isServerEnvironment() {
|
|
1007
|
+
return !!(process.env.SSH_CONNECTION || process.env.SSH_CLIENT ||
|
|
1008
|
+
(os.platform() === 'linux' && !process.env.DISPLAY));
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function hasCloudflared() {
|
|
1012
|
+
try { execSync('which cloudflared', { stdio: 'pipe' }); return true; } catch { return false; }
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function installCloudflared() {
|
|
1016
|
+
log('Installing cloudflared...');
|
|
1017
|
+
const platform = os.platform();
|
|
1018
|
+
try {
|
|
1019
|
+
if (platform === 'linux') {
|
|
1020
|
+
execSync('curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /tmp/cloudflared && chmod +x /tmp/cloudflared && sudo mv /tmp/cloudflared /usr/local/bin/cloudflared', { stdio: 'pipe' });
|
|
1021
|
+
} else if (platform === 'darwin') {
|
|
1022
|
+
execSync('brew install cloudflared', { stdio: 'pipe' });
|
|
1023
|
+
}
|
|
1024
|
+
return true;
|
|
1025
|
+
} catch {
|
|
1026
|
+
warn('Could not install cloudflared automatically.');
|
|
1027
|
+
return false;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function createSetupTunnel(port, tunnelDomain) {
|
|
1032
|
+
if (!hasCloudflared() && !installCloudflared()) return null;
|
|
1033
|
+
if (!hasCloudflared()) return null;
|
|
1034
|
+
|
|
1035
|
+
const tunnelId = crypto.randomBytes(4).toString('hex');
|
|
1036
|
+
|
|
1037
|
+
// Admin mode: branded subdomain (requires cloudflared login for the zone)
|
|
1038
|
+
if (tunnelDomain) {
|
|
1039
|
+
const tunnelName = `limbo-setup-${tunnelId}`;
|
|
1040
|
+
const subdomain = `setup-${tunnelId}.${tunnelDomain}`;
|
|
1041
|
+
try {
|
|
1042
|
+
execSync(`cloudflared tunnel create ${tunnelName}`, { stdio: 'pipe', encoding: 'utf8' });
|
|
1043
|
+
execSync(`cloudflared tunnel route dns ${tunnelName} ${subdomain}`, { stdio: 'pipe', encoding: 'utf8' });
|
|
1044
|
+
|
|
1045
|
+
const cfHome = path.join(os.homedir(), '.cloudflared');
|
|
1046
|
+
const tunnelInfoRaw = execSync(`cloudflared tunnel info ${tunnelName} 2>&1`, { encoding: 'utf8', stdio: 'pipe' });
|
|
1047
|
+
const tunnelUuid = tunnelInfoRaw.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/)?.[1];
|
|
1048
|
+
if (!tunnelUuid) throw new Error('Could not find tunnel UUID');
|
|
1049
|
+
|
|
1050
|
+
const credFile = path.join(cfHome, `${tunnelUuid}.json`);
|
|
1051
|
+
const configPath = path.join(LIMBO_DIR, 'cloudflared-setup.yml');
|
|
1052
|
+
fs.writeFileSync(configPath, [
|
|
1053
|
+
`tunnel: ${tunnelUuid}`,
|
|
1054
|
+
`credentials-file: ${credFile}`,
|
|
1055
|
+
'ingress:',
|
|
1056
|
+
` - hostname: ${subdomain}`,
|
|
1057
|
+
` service: http://localhost:${port}`,
|
|
1058
|
+
' - service: http_status:404',
|
|
1059
|
+
].join('\n'));
|
|
1060
|
+
|
|
1061
|
+
const tunnelProc = spawn('cloudflared', ['tunnel', '--config', configPath, 'run'], {
|
|
1062
|
+
detached: true, stdio: 'ignore',
|
|
1063
|
+
});
|
|
1064
|
+
tunnelProc.unref();
|
|
1065
|
+
sleep(5000);
|
|
1066
|
+
|
|
1067
|
+
return { type: 'branded', url: `https://${subdomain}`, tunnelName, tunnelUuid, configPath, pid: tunnelProc.pid };
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
warn(`Could not create branded tunnel: ${err.message}`);
|
|
1070
|
+
warn('Make sure you ran `cloudflared login` for this domain first.');
|
|
1071
|
+
// Fall through to quick tunnel
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Default: quick tunnel (zero config, works for everyone)
|
|
1076
|
+
try {
|
|
1077
|
+
const logFile = path.join(LIMBO_DIR, 'cloudflared-setup.log');
|
|
1078
|
+
const tunnelProc = spawn('cloudflared', ['tunnel', '--no-autoupdate', '--url', `http://localhost:${port}`], {
|
|
1079
|
+
detached: true,
|
|
1080
|
+
stdio: ['ignore', fs.openSync(logFile, 'w'), fs.openSync(logFile, 'a')],
|
|
1081
|
+
});
|
|
1082
|
+
tunnelProc.unref();
|
|
1083
|
+
|
|
1084
|
+
for (let i = 0; i < 15; i++) {
|
|
1085
|
+
sleep(1000);
|
|
1086
|
+
try {
|
|
1087
|
+
const logs = fs.readFileSync(logFile, 'utf8');
|
|
1088
|
+
const match = logs.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
1089
|
+
if (match) return { type: 'quick', url: match[0], pid: tunnelProc.pid, logFile };
|
|
1090
|
+
} catch {}
|
|
1091
|
+
}
|
|
1092
|
+
warn('Could not start cloudflare tunnel.');
|
|
1093
|
+
return null;
|
|
1094
|
+
} catch {
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function teardownSetupTunnel(tunnel) {
|
|
1100
|
+
if (!tunnel) return;
|
|
1101
|
+
try { process.kill(tunnel.pid); } catch {}
|
|
1102
|
+
|
|
1103
|
+
if (tunnel.type === 'branded') {
|
|
1104
|
+
try { execSync(`cloudflared tunnel delete -f ${tunnel.tunnelName}`, { stdio: 'pipe' }); } catch {}
|
|
1105
|
+
try { fs.unlinkSync(tunnel.configPath); } catch {}
|
|
1106
|
+
}
|
|
1107
|
+
if (tunnel.logFile) try { fs.unlinkSync(tunnel.logFile); } catch {}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function installGlobalAlias() {
|
|
1111
|
+
// Create a `limbo` shell wrapper so users don't have to type `npx limbo-ai` every time.
|
|
1112
|
+
// Tries /usr/local/bin first (macOS, Linux with sudo), falls back to ~/.local/bin (no sudo).
|
|
1113
|
+
const wrapper = '#!/bin/sh\nexec npx limbo-ai "$@"\n';
|
|
1114
|
+
const candidates = [
|
|
1115
|
+
path.join(os.homedir(), '.local', 'bin', 'limbo'),
|
|
1116
|
+
'/usr/local/bin/limbo',
|
|
1117
|
+
];
|
|
1118
|
+
|
|
1119
|
+
for (const target of candidates) {
|
|
1120
|
+
try {
|
|
1121
|
+
// Skip if already installed and current
|
|
1122
|
+
if (fs.existsSync(target)) {
|
|
1123
|
+
const existing = fs.readFileSync(target, 'utf8');
|
|
1124
|
+
if (existing.includes('limbo-ai')) return;
|
|
1125
|
+
}
|
|
1126
|
+
const dir = path.dirname(target);
|
|
1127
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
1128
|
+
fs.writeFileSync(target, wrapper, { mode: 0o755 });
|
|
1129
|
+
log(`Installed ${c.bold}limbo${c.reset} command → ${target}`);
|
|
1130
|
+
return;
|
|
1131
|
+
} catch {
|
|
1132
|
+
// Permission denied — try next candidate
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
// Silent failure — not critical, user can still use npx limbo-ai
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1004
1138
|
function isOomError(stderr) {
|
|
1005
1139
|
return typeof stderr === 'string' && (
|
|
1006
1140
|
stderr.includes('heap out of memory') ||
|
|
@@ -1393,8 +1527,18 @@ function extractWizardUrl(maxAttempts = 15) {
|
|
|
1393
1527
|
return null;
|
|
1394
1528
|
}
|
|
1395
1529
|
|
|
1396
|
-
function printWizardUrl(url) {
|
|
1397
|
-
|
|
1530
|
+
function printWizardUrl(url, tunnel) {
|
|
1531
|
+
// Extract token from the original URL
|
|
1532
|
+
const tokenMatch = url.match(/[?&]token=([^&\s]+)/);
|
|
1533
|
+
const token = tokenMatch ? tokenMatch[1] : '';
|
|
1534
|
+
|
|
1535
|
+
let displayUrl;
|
|
1536
|
+
if (tunnel) {
|
|
1537
|
+
displayUrl = `${tunnel.url}/?token=${token}`;
|
|
1538
|
+
} else {
|
|
1539
|
+
displayUrl = url.replace('0.0.0.0', '127.0.0.1');
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1398
1542
|
console.log(`
|
|
1399
1543
|
${c.green}${c.bold}╔════════════════════════════════════════════════════════╗${c.reset}
|
|
1400
1544
|
${c.green}${c.bold}║ Setup wizard is ready! ║${c.reset}
|
|
@@ -1403,14 +1547,15 @@ ${c.green}${c.bold}╚═══════════════════
|
|
|
1403
1547
|
Open this URL to complete setup:
|
|
1404
1548
|
|
|
1405
1549
|
${c.cyan}${c.bold}${displayUrl}${c.reset}
|
|
1406
|
-
|
|
1550
|
+
${tunnel ? `
|
|
1551
|
+
${c.green}🔒 Secured via Cloudflare (${tunnel.type === 'branded' ? tunnel.url.replace('https://', '') : 'HTTPS tunnel'})${c.reset}` : ''}
|
|
1407
1552
|
The wizard will guide you through provider, API key, and model selection.
|
|
1408
1553
|
Once complete, Limbo will restart and be ready to use.
|
|
1409
1554
|
|
|
1410
1555
|
${c.dim}Logs: limbo logs | Stop: limbo stop${c.reset}
|
|
1411
1556
|
`);
|
|
1412
1557
|
// Auto-open on macOS
|
|
1413
|
-
if (os.platform() === 'darwin') {
|
|
1558
|
+
if (os.platform() === 'darwin' && !tunnel) {
|
|
1414
1559
|
try { execSync(`open "${displayUrl}"`, { stdio: 'pipe' }); } catch {}
|
|
1415
1560
|
}
|
|
1416
1561
|
}
|
|
@@ -1472,6 +1617,7 @@ async function cmdStart() {
|
|
|
1472
1617
|
const flagApiKey = parseFlag('--api-key');
|
|
1473
1618
|
const flagModel = parseFlag('--model');
|
|
1474
1619
|
const flagLang = parseFlag('--language') || 'en';
|
|
1620
|
+
const flagTunnelDomain = parseFlag('--tunnel-domain');
|
|
1475
1621
|
|
|
1476
1622
|
if (flagProvider) {
|
|
1477
1623
|
const validProviders = ['openai', 'anthropic', 'openrouter'];
|
|
@@ -1570,7 +1716,13 @@ async function cmdStart() {
|
|
|
1570
1716
|
|
|
1571
1717
|
const wizardUrl = extractWizardUrl();
|
|
1572
1718
|
if (wizardUrl) {
|
|
1573
|
-
|
|
1719
|
+
// On servers, create a secure tunnel for remote access
|
|
1720
|
+
let tunnel = null;
|
|
1721
|
+
if (isServerEnvironment()) {
|
|
1722
|
+
log('Server environment detected — creating secure tunnel...');
|
|
1723
|
+
tunnel = createSetupTunnel(PORT, flagTunnelDomain);
|
|
1724
|
+
}
|
|
1725
|
+
printWizardUrl(wizardUrl, tunnel);
|
|
1574
1726
|
} else {
|
|
1575
1727
|
// Fallback: container may have started without setup mode (e.g. config already inside volume)
|
|
1576
1728
|
console.log(`
|
|
@@ -1623,6 +1775,8 @@ async function startContainerWithConfig(cfg, existingEnv, alreadyHasEnv) {
|
|
|
1623
1775
|
|
|
1624
1776
|
console.log(`\n ${c.yellow}⚠ ${t(cfg.language, 'securityNotice')}${c.reset}\n`);
|
|
1625
1777
|
|
|
1778
|
+
installGlobalAlias();
|
|
1779
|
+
|
|
1626
1780
|
printSuccess({
|
|
1627
1781
|
language: cfg.language,
|
|
1628
1782
|
telegramEnabled: mergedEnv.TELEGRAM_ENABLED || cfg.telegramEnabled || 'false',
|
|
@@ -1708,6 +1862,7 @@ ${c.bold}Flags:${c.reset}
|
|
|
1708
1862
|
--api-key <key> API key for headless install
|
|
1709
1863
|
--model <name> Model name (optional, uses provider default)
|
|
1710
1864
|
--language <code> Language: en, es (default: en)
|
|
1865
|
+
--tunnel-domain <d> Admin: use branded subdomain for setup tunnel (e.g. limbo.tomasward.com)
|
|
1711
1866
|
|
|
1712
1867
|
${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
|
|
1713
1868
|
`);
|