openclaw-nerve/scripts/setup.ts
2026-02-19 22:28:07 +01:00

851 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Interactive setup wizard for Nerve.
* Guides users through first-time configuration.
*
* Usage:
* npm run setup # Interactive setup
* npm run setup -- --check # Validate existing config
* npm run setup -- --defaults # Non-interactive with defaults
*/
/** Mask a token for display, with a guard for short tokens. */
// Show token in prompts so users can verify what they entered
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { networkInterfaces } from 'node:os';
import { input, password, confirm, select } from '@inquirer/prompts';
import { printBanner, section, success, warn, fail, info, dim, promptTheme } from './lib/banner.js';
import { checkPrerequisites, type PrereqResult } from './lib/prereq-check.js';
import {
isValidUrl,
isValidPort,
testGatewayConnection,
isValidOpenAIKey,
isValidReplicateToken,
} from './lib/validators.js';
import {
writeEnvFile,
backupExistingEnv,
loadExistingEnv,
cleanupTmp,
DEFAULTS,
type EnvConfig,
} from './lib/env-writer.js';
import { generateSelfSignedCert } from './lib/cert-gen.js';
import { detectGatewayConfig, getEnvGatewayToken, patchGatewayAllowedOrigins, patchGatewayBind, restartGateway } from './lib/gateway-detect.js';
const PROJECT_ROOT = resolve(process.cwd());
const ENV_PATH = resolve(PROJECT_ROOT, '.env');
const TOTAL_SECTIONS = 5;
const args = process.argv.slice(2);
const isHelp = args.includes('--help') || args.includes('-h');
const isCheck = args.includes('--check');
const isDefaults = args.includes('--defaults');
// ── Ctrl+C handler ───────────────────────────────────────────────────
process.on('SIGINT', () => {
cleanupTmp(ENV_PATH);
console.log('\n\n Setup cancelled.\n');
process.exit(130);
});
// ── Main ─────────────────────────────────────────────────────────────
async function main(): Promise<void> {
if (isHelp) {
console.log(`
Usage: npm run setup [options]
Options:
--check Validate existing .env config and test gateway connection
--defaults Non-interactive setup using auto-detected values
--help, -h Show this help message
The setup wizard guides you through 5 steps:
1. Gateway Connection — connect to your OpenClaw gateway
2. Agent Identity — set your agent's display name
3. Access Mode — local, Tailscale, LAN, or custom
4. TTS Configuration — optional text-to-speech API keys
5. Advanced Settings — custom file paths (most users skip this)
Examples:
npm run setup # Interactive setup
npm run setup -- --check # Validate existing config
npm run setup -- --defaults # Auto-configure with detected values
`);
return;
}
printBanner(); // no-ops when NERVE_INSTALLER is set
// Clean up stale .env.tmp from previous interrupted runs
cleanupTmp(ENV_PATH);
// Prerequisite checks (skip verbose output when called from installer — already checked)
const prereqs = checkPrerequisites({ quiet: !!process.env.NERVE_INSTALLER });
if (!prereqs.nodeOk) {
console.log('');
fail('Node.js ≥ 22 is required. Please upgrade and try again.');
process.exit(1);
}
// Load existing config as defaults
const hasExisting = existsSync(ENV_PATH);
const existing: EnvConfig = hasExisting ? loadExistingEnv(ENV_PATH) : {};
if (hasExisting) {
info('Found existing .env configuration');
} else {
info('No existing .env found — starting fresh setup');
}
// --check mode: validate and exit
if (isCheck) {
await runCheck(existing);
return;
}
// --defaults mode: non-interactive
if (isDefaults) {
await runDefaults(existing);
return;
}
// If .env exists, ask whether to update or start fresh
// (Skip this when called from install.sh — the installer already asked)
if (hasExisting && existing.GATEWAY_TOKEN && !process.env.NERVE_INSTALLER) {
const action = await select({
theme: promptTheme,
message: 'What would you like to do?',
choices: [
{ name: 'Update existing configuration', value: 'update' },
{ name: 'Start fresh', value: 'fresh' },
{ name: 'Cancel', value: 'cancel' },
],
});
if (action === 'cancel') {
console.log('\n Setup cancelled.\n');
return;
}
if (action === 'fresh') {
Object.keys(existing).forEach((k) => delete (existing as Record<string, unknown>)[k]);
}
}
// Run interactive setup
const config = await collectInteractive(existing, prereqs);
// Write .env
if (hasExisting) {
const backupPath = backupExistingEnv(ENV_PATH);
info(`Previous config backed up to ${backupPath.replace(PROJECT_ROOT + '/', '')}`);
}
writeEnvFile(ENV_PATH, config);
console.log('');
success('Configuration written to .env');
printSummary(config);
// When invoked from install.sh, build is already done — skip misleading "next steps"
if (!process.env.NERVE_INSTALLER) {
printNextSteps(config);
}
}
// ── Interactive setup ────────────────────────────────────────────────
async function collectInteractive(
existing: EnvConfig,
prereqs: PrereqResult,
): Promise<EnvConfig> {
const config: EnvConfig = { ...existing };
// ── 1/5: Gateway Connection ──────────────────────────────────────
section(1, TOTAL_SECTIONS, 'Gateway Connection');
dim('Nerve connects to your OpenClaw gateway.');
dim('Make sure the gateway is running before continuing.');
console.log('');
// Auto-detect gateway config
const detected = detectGatewayConfig();
const envToken = getEnvGatewayToken();
// Determine default token (priority: existing > env > detected)
const defaultToken = existing.GATEWAY_TOKEN || envToken || detected.token || '';
const defaultUrl = existing.GATEWAY_URL || detected.url || DEFAULTS.GATEWAY_URL;
if (detected.token && !existing.GATEWAY_TOKEN) {
success('Auto-detected gateway token from ~/.openclaw/openclaw.json');
}
if (envToken && !existing.GATEWAY_TOKEN && !detected.token) {
success('Found OPENCLAW_GATEWAY_TOKEN in environment');
}
config.GATEWAY_URL = await input({
theme: promptTheme,
message: 'Gateway URL',
default: defaultUrl,
validate: (val) => {
if (!isValidUrl(val)) return 'Please enter a valid HTTP(S) URL';
return true;
},
});
// If we have an auto-detected token, offer to use it
if (defaultToken && !existing.GATEWAY_TOKEN) {
const useDetected = await confirm({
theme: promptTheme,
message: `Use detected token (${defaultToken})?`,
default: true,
});
if (useDetected) {
config.GATEWAY_TOKEN = defaultToken;
} else {
config.GATEWAY_TOKEN = await password({
theme: promptTheme,
message: 'Gateway Auth Token (required)',
validate: (val) => {
if (!val || !val.trim()) return 'Gateway token is required';
return true;
},
});
}
} else if (existing.GATEWAY_TOKEN) {
// Existing token — offer to keep it
const keepExisting = await confirm({
theme: promptTheme,
message: `Keep existing gateway token (${existing.GATEWAY_TOKEN})?`,
default: true,
});
if (keepExisting) {
config.GATEWAY_TOKEN = existing.GATEWAY_TOKEN;
} else {
config.GATEWAY_TOKEN = await password({
theme: promptTheme,
message: 'Gateway Auth Token (required)',
validate: (val) => {
if (!val || !val.trim()) return 'Gateway token is required';
return true;
},
});
}
} else {
dim('Find your token in ~/.openclaw/openclaw.json or run: openclaw gateway status');
config.GATEWAY_TOKEN = await password({
theme: promptTheme,
message: 'Gateway Auth Token (required)',
validate: (val) => {
if (!val || !val.trim()) return 'Gateway token is required';
return true;
},
});
}
// Test connection
const rail = ` \x1b[2m│\x1b[0m`;
const testPrefix = process.env.NERVE_INSTALLER ? `${rail} ` : ' ';
process.stdout.write(`${testPrefix}Testing connection... `);
const gwTest = await testGatewayConnection(config.GATEWAY_URL!);
if (gwTest.ok) {
console.log(`\x1b[32m✓\x1b[0m ${gwTest.message}`);
} else {
console.log(`\x1b[31m✗\x1b[0m ${gwTest.message}`);
dim(' Start it with: openclaw gateway start');
const proceed = await confirm({
theme: promptTheme,
message: 'Gateway is unreachable. Continue with this URL anyway?',
default: false,
});
if (!proceed) {
console.log('\n Start your gateway with: \x1b[36mopenclaw gateway start\x1b[0m');
console.log(' Then re-run: \x1b[36mnpm run setup\x1b[0m\n');
process.exit(1);
}
}
// ── 2/5: Agent Identity ──────────────────────────────────────────
section(2, TOTAL_SECTIONS, 'Agent Identity');
config.AGENT_NAME = await input({
theme: promptTheme,
message: 'Agent display name',
default: existing.AGENT_NAME || DEFAULTS.AGENT_NAME,
});
// ── 3/5: Access Mode ──────────────────────────────────────────────
section(3, TOTAL_SECTIONS, 'How will you access Nerve?');
// Build access mode choices dynamically
type AccessMode = 'local' | 'tailscale' | 'network' | 'custom';
const accessChoices: { name: string; value: AccessMode; description: string }[] = [
{ name: 'This machine only (localhost)', value: 'local', description: 'Safest — only accessible from this computer' },
];
if (prereqs.tailscaleIp) {
accessChoices.push({
name: `Via Tailscale (${prereqs.tailscaleIp})`,
value: 'tailscale',
description: 'Access from any device on your Tailscale network — secure, no port forwarding needed',
});
}
accessChoices.push(
{ name: 'From other devices on my network', value: 'network', description: 'Opens to LAN — you may need to configure your firewall' },
{ name: 'Custom setup (I know what I\'m doing)', value: 'custom', description: 'Manual port, bind address, HTTPS, CORS configuration' },
);
const accessMode = await select<AccessMode>({
theme: promptTheme,
message: 'How will you connect to Nerve?',
choices: accessChoices,
});
const port = existing.PORT || DEFAULTS.PORT;
config.PORT = port;
// Helper: offer HTTPS setup for non-localhost access modes (voice input needs secure context)
async function offerHttpsSetup(remoteIp: string): Promise<void> {
console.log('');
warn('Voice input (microphone) requires HTTPS on non-localhost connections.');
dim('Browsers block microphone access over plain HTTP for security.');
console.log('');
const enableHttps = await confirm({
theme: promptTheme,
message: 'Enable HTTPS? (recommended for voice input)',
default: true,
});
if (enableHttps) {
let certsReady = false;
if (prereqs.opensslOk) {
const certResult = generateSelfSignedCert(PROJECT_ROOT);
if (certResult.ok) {
success(certResult.message);
certsReady = true;
} else {
fail(certResult.message);
}
} else {
warn('openssl not found — cannot generate self-signed certificate');
dim('Install openssl and run: mkdir -p certs && openssl req -x509 -newkey rsa:2048 \\');
dim(' -keyout certs/key.pem -out certs/cert.pem -days 365 -nodes -subj "/CN=localhost"');
}
if (certsReady) {
const sslPort = existing.SSL_PORT || DEFAULTS.SSL_PORT;
config.SSL_PORT = sslPort;
// Add HTTPS origins to CORS and CSP
const httpsUrl = `https://${remoteIp}:${sslPort}`;
const existingOrigins = config.ALLOWED_ORIGINS || '';
config.ALLOWED_ORIGINS = existingOrigins ? `${existingOrigins},${httpsUrl}` : httpsUrl;
const existingCsp = config.CSP_CONNECT_EXTRA || '';
config.CSP_CONNECT_EXTRA = existingCsp
? `${existingCsp} ${httpsUrl} wss://${remoteIp}:${sslPort}`
: `${httpsUrl} wss://${remoteIp}:${sslPort}`;
success(`HTTPS will be available at ${httpsUrl}`);
dim('Note: Self-signed certs will show a browser warning on first visit — click "Advanced" → "Proceed"');
} else {
warn('HTTPS disabled — voice input will only work on localhost');
}
} else {
dim('Voice input will only work when accessing Nerve from localhost');
}
}
if (accessMode === 'local') {
config.HOST = '127.0.0.1';
success(`Nerve will be available at http://localhost:${port}`);
} else if (accessMode === 'tailscale') {
config.HOST = '0.0.0.0';
const tsIp = prereqs.tailscaleIp!;
const tsUrl = `http://${tsIp}:${port}`;
config.ALLOWED_ORIGINS = tsUrl;
config.WS_ALLOWED_HOSTS = tsIp;
config.CSP_CONNECT_EXTRA = `${tsUrl} ws://${tsIp}:${port}`;
success(`Nerve will be available at ${tsUrl}`);
dim('Accessible from any device on your Tailscale network');
await offerHttpsSetup(tsIp);
} else if (accessMode === 'network') {
config.HOST = '0.0.0.0';
// Auto-detect LAN IP
const detectedIp = (() => {
const nets = networkInterfaces();
for (const addrs of Object.values(nets)) {
for (const addr of addrs ?? []) {
if (!addr.internal && addr.family === 'IPv4') return addr.address;
}
}
return null;
})();
const lanIp = await input({
theme: promptTheme,
message: 'Your LAN IP address',
default: detectedIp || '',
validate: (val) => {
if (!val.trim()) return 'IP address is required for network access';
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(val.trim())) return 'Enter a valid IPv4 address';
return true;
},
});
const ip = lanIp.trim();
const lanUrl = `http://${ip}:${port}`;
config.ALLOWED_ORIGINS = lanUrl;
config.WS_ALLOWED_HOSTS = ip;
config.CSP_CONNECT_EXTRA = `${lanUrl} ws://${ip}:${port}`;
success(`Nerve will be available at ${lanUrl}`);
dim('Make sure your firewall allows traffic on port ' + port);
dim('Need access from multiple devices? Add more origins to ALLOWED_ORIGINS in .env');
await offerHttpsSetup(ip);
} else {
// Custom — full manual control
const portStr = await input({
theme: promptTheme,
message: 'HTTP port',
default: existing.PORT || DEFAULTS.PORT,
validate: (val) => {
const n = parseInt(val, 10);
if (!isValidPort(n)) return 'Please enter a valid port (165535)';
return true;
},
});
config.PORT = portStr;
config.HOST = await input({
theme: promptTheme,
message: 'Bind address (127.0.0.1 = local only, 0.0.0.0 = all interfaces)',
default: existing.HOST || DEFAULTS.HOST,
});
// HTTPS
const enableHttps = await confirm({
theme: promptTheme,
message: 'Enable HTTPS? (needed for microphone access over network)',
default: false,
});
if (enableHttps) {
let certsReady = false;
if (prereqs.opensslOk) {
const certResult = generateSelfSignedCert(PROJECT_ROOT);
if (certResult.ok) {
success(certResult.message);
certsReady = true;
} else {
fail(certResult.message);
}
} else {
warn('openssl not found — cannot generate self-signed certificate');
dim('Install openssl and run: mkdir -p certs && openssl req -x509 -newkey rsa:2048 \\');
dim(' -keyout certs/key.pem -out certs/cert.pem -days 365 -nodes -subj "/CN=localhost"');
}
if (certsReady) {
config.SSL_PORT = await input({
theme: promptTheme,
message: 'SSL port',
default: existing.SSL_PORT || DEFAULTS.SSL_PORT,
validate: (val) => {
const n = parseInt(val, 10);
if (!isValidPort(n)) return 'Please enter a valid port (165535)';
if (n === parseInt(config.PORT || DEFAULTS.PORT, 10)) return 'SSL port must differ from HTTP port';
return true;
},
});
// Add HTTPS/WSS origins to CORS and CSP when bound to a non-loopback address
const customHost = config.HOST || DEFAULTS.HOST;
if (customHost !== '127.0.0.1' && customHost !== 'localhost' && customHost !== '::1') {
const httpsUrl = `https://${customHost}:${config.SSL_PORT}`;
config.ALLOWED_ORIGINS = config.ALLOWED_ORIGINS
? `${config.ALLOWED_ORIGINS},${httpsUrl}`
: httpsUrl;
config.CSP_CONNECT_EXTRA = config.CSP_CONNECT_EXTRA
? `${config.CSP_CONNECT_EXTRA} ${httpsUrl} wss://${customHost}:${config.SSL_PORT}`
: `${httpsUrl} wss://${customHost}:${config.SSL_PORT}`;
}
} else {
warn('HTTPS disabled — no certificates available');
dim('You can generate certs manually and add SSL_PORT to .env later');
}
}
}
// ── Patch gateway for external access ─────────────────────────────
if (accessMode !== 'local') {
const nervePort = config.PORT || DEFAULTS.PORT;
// Extract the real IP — 0.0.0.0 isn't a valid origin for browsers
let accessIp = config.HOST === '0.0.0.0'
? (config.ALLOWED_ORIGINS?.split(',')[0]?.replace(/^https?:\/\//, '').replace(/:\d+$/, '') || 'localhost')
: (config.HOST || 'localhost');
if (accessIp === '0.0.0.0') {
// Detect actual LAN IP as fallback
const nets = networkInterfaces();
for (const addrs of Object.values(nets)) {
for (const addr of addrs ?? []) {
if (!addr.internal && addr.family === 'IPv4') { accessIp = addr.address; break; }
}
if (accessIp !== '0.0.0.0') break;
}
}
const nerveOrigin = `http://${accessIp}:${nervePort}`;
const sslPort = config.SSL_PORT;
const nerveHttpsOrigin = sslPort ? `https://${accessIp}:${sslPort}` : null;
console.log('');
warn('External access requires updating the OpenClaw gateway config.');
dim('Without this, the gateway will reject WebSocket connections from Nerve.');
console.log('');
dim(' This will:');
dim(` 1. Set gateway.bind to "lan" (listen on all interfaces)`);
dim(` 2. Add ${nerveOrigin} to gateway.controlUi.allowedOrigins`);
if (nerveHttpsOrigin) {
dim(` 3. Add ${nerveHttpsOrigin} to gateway.controlUi.allowedOrigins`);
}
dim(` Config file: ~/.openclaw/openclaw.json`);
console.log('');
const patchGateway = await confirm({
theme: promptTheme,
message: 'Update OpenClaw gateway config to allow Nerve connections?',
default: true,
});
if (patchGateway) {
const bindResult = patchGatewayBind('lan');
if (bindResult.ok) {
success(bindResult.message);
} else {
warn(bindResult.message);
}
const httpResult = patchGatewayAllowedOrigins(nerveOrigin);
if (httpResult.ok) {
success(httpResult.message);
} else {
warn(httpResult.message);
dim('You can manually add the origin to gateway.controlUi.allowedOrigins in ~/.openclaw/openclaw.json');
}
if (nerveHttpsOrigin) {
const httpsResult = patchGatewayAllowedOrigins(nerveHttpsOrigin);
if (httpsResult.ok) {
success(httpsResult.message);
} else {
warn(httpsResult.message);
}
}
// Auto-restart gateway to apply changes
const restartResult = restartGateway();
if (restartResult.ok) {
success(restartResult.message);
} else {
warn(restartResult.message);
}
} else {
warn('Skipped — you may see "origin not allowed" errors in Nerve.');
dim('To fix later, add the origin to gateway.controlUi.allowedOrigins in ~/.openclaw/openclaw.json');
}
}
// ── 4/5: TTS ─────────────────────────────────────────────────────
section(4, TOTAL_SECTIONS, 'Text-to-Speech (optional)');
dim('Edge TTS is always available (free, no API key needed).');
dim('Add API keys below for higher-quality alternatives.');
console.log('');
const openaiKey = await password({
theme: promptTheme,
message: 'OpenAI API Key (press Enter to skip)',
});
if (openaiKey && openaiKey.trim()) {
if (isValidOpenAIKey(openaiKey.trim())) {
config.OPENAI_API_KEY = openaiKey.trim();
success('OpenAI API key accepted (enables TTS + Whisper transcription)');
} else {
warn('Key doesn\'t look like a standard OpenAI key (expected sk-...)');
const useAnyway = await confirm({
theme: promptTheme,
message: 'Use this key anyway?',
default: true,
});
if (useAnyway) {
config.OPENAI_API_KEY = openaiKey.trim();
}
}
}
const replicateToken = await password({
theme: promptTheme,
message: 'Replicate API Token (press Enter to skip)',
});
if (replicateToken && replicateToken.trim()) {
if (isValidReplicateToken(replicateToken.trim())) {
config.REPLICATE_API_TOKEN = replicateToken.trim();
success('Replicate token accepted (enables Qwen TTS)');
if (!prereqs.ffmpegOk) {
warn('ffmpeg not found — Qwen TTS requires it for WAV→MP3 conversion');
}
} else {
warn('Token seems too short');
const useAnyway = await confirm({
theme: promptTheme,
message: 'Use this token anyway?',
default: true,
});
if (useAnyway) {
config.REPLICATE_API_TOKEN = replicateToken.trim();
}
}
}
// ── 5/5: Advanced Settings ────────────────────────────────────────
section(5, TOTAL_SECTIONS, 'Advanced Settings (optional)');
const configureAdvanced = await confirm({
theme: promptTheme,
message: 'Customize file paths? (most users should skip this)',
default: false,
});
if (configureAdvanced) {
const memPath = await input({
theme: promptTheme,
message: 'Custom memory file path (or Enter for default)',
default: existing.MEMORY_PATH || '',
});
if (memPath.trim()) config.MEMORY_PATH = memPath.trim();
const memDir = await input({
theme: promptTheme,
message: 'Custom memory directory path (or Enter for default)',
default: existing.MEMORY_DIR || '',
});
if (memDir.trim()) config.MEMORY_DIR = memDir.trim();
const sessDir = await input({
theme: promptTheme,
message: 'Custom sessions directory (or Enter for default)',
default: existing.SESSIONS_DIR || '',
});
if (sessDir.trim()) config.SESSIONS_DIR = sessDir.trim();
} else {
// Preserve any existing advanced settings on update
if (existing.MEMORY_PATH) config.MEMORY_PATH = existing.MEMORY_PATH;
if (existing.MEMORY_DIR) config.MEMORY_DIR = existing.MEMORY_DIR;
if (existing.SESSIONS_DIR) config.SESSIONS_DIR = existing.SESSIONS_DIR;
if (existing.USAGE_FILE) config.USAGE_FILE = existing.USAGE_FILE;
}
return config;
}
// ── Summary and next steps ───────────────────────────────────────────
function printSummary(config: EnvConfig): void {
const gwUrl = config.GATEWAY_URL || DEFAULTS.GATEWAY_URL;
const agentName = config.AGENT_NAME || DEFAULTS.AGENT_NAME;
const port = config.PORT || DEFAULTS.PORT;
const sslPort = config.SSL_PORT || DEFAULTS.SSL_PORT;
const host = config.HOST || DEFAULTS.HOST;
const hasCerts = existsSync(resolve(PROJECT_ROOT, 'certs', 'cert.pem'));
let ttsProvider = 'Edge (free)';
if (config.OPENAI_API_KEY && config.REPLICATE_API_TOKEN) {
ttsProvider = 'OpenAI + Replicate + Edge';
} else if (config.OPENAI_API_KEY) {
ttsProvider = 'OpenAI + Edge (fallback)';
} else if (config.REPLICATE_API_TOKEN) {
ttsProvider = 'Replicate + Edge (fallback)';
}
const hostLabel = host === '127.0.0.1' ? '127.0.0.1 (local only)' : `${host} (network)`;
if (process.env.NERVE_INSTALLER) {
// Rail-style summary — stays inside the installer's visual flow
const r = ` \x1b[2m│\x1b[0m`;
console.log('');
console.log(`${r} \x1b[2mGateway${' '.repeat(4)}\x1b[0m${gwUrl}`);
console.log(`${r} \x1b[2mAgent${' '.repeat(6)}\x1b[0m${agentName}`);
console.log(`${r} \x1b[2mHTTP${' '.repeat(7)}\x1b[0m:${port}`);
if (hasCerts) {
console.log(`${r} \x1b[2mHTTPS${' '.repeat(6)}\x1b[0m:${sslPort}`);
}
console.log(`${r} \x1b[2mTTS${' '.repeat(8)}\x1b[0m${ttsProvider}`);
console.log(`${r} \x1b[2mHost${' '.repeat(7)}\x1b[0m${hostLabel}`);
} else {
// Standalone mode — boxed summary
console.log('');
console.log(' \x1b[2m┌─────────────────────────────────────────┐\x1b[0m');
console.log(` \x1b[2m│\x1b[0m Gateway ${gwUrl.padEnd(28)}\x1b[2m│\x1b[0m`);
console.log(` \x1b[2m│\x1b[0m Agent ${agentName.padEnd(28)}\x1b[2m│\x1b[0m`);
console.log(` \x1b[2m│\x1b[0m HTTP :${port.padEnd(27)}\x1b[2m│\x1b[0m`);
if (hasCerts) {
console.log(` \x1b[2m│\x1b[0m HTTPS :${sslPort.padEnd(27)}\x1b[2m│\x1b[0m`);
}
console.log(` \x1b[2m│\x1b[0m TTS ${ttsProvider.padEnd(28)}\x1b[2m│\x1b[0m`);
console.log(` \x1b[2m│\x1b[0m Host ${hostLabel.padEnd(28)}\x1b[2m│\x1b[0m`);
console.log(' \x1b[2m└─────────────────────────────────────────┘\x1b[0m');
}
}
function printNextSteps(config: EnvConfig): void {
const port = config.PORT || DEFAULTS.PORT;
console.log('');
console.log(' \x1b[1mNext steps:\x1b[0m');
console.log(` Development: \x1b[36mnpm run dev\x1b[0m && \x1b[36mnpm run dev:server\x1b[0m`);
console.log(` Production: \x1b[36mnpm run prod\x1b[0m`);
console.log('');
console.log(` Open \x1b[36mhttp://localhost:${port}\x1b[0m in your browser.`);
console.log('');
}
// ── --check mode ─────────────────────────────────────────────────────
async function runCheck(config: EnvConfig): Promise<void> {
console.log('');
console.log(' \x1b[1mValidating configuration...\x1b[0m');
console.log('');
let errors = 0;
// Gateway token
if (config.GATEWAY_TOKEN) {
success('GATEWAY_TOKEN is set');
} else {
fail('GATEWAY_TOKEN is missing (required)');
errors++;
}
// Gateway URL
const gwUrl = config.GATEWAY_URL || DEFAULTS.GATEWAY_URL;
if (isValidUrl(gwUrl)) {
success(`GATEWAY_URL is valid: ${gwUrl}`);
// Test connectivity
process.stdout.write(' Testing gateway connection... ');
const gwTest = await testGatewayConnection(gwUrl);
if (gwTest.ok) {
console.log(`\x1b[32m✓\x1b[0m ${gwTest.message}`);
} else {
console.log(`\x1b[33m⚠\x1b[0m ${gwTest.message}`);
}
} else {
fail(`GATEWAY_URL is invalid: ${gwUrl}`);
errors++;
}
// Port
const port = parseInt(config.PORT || DEFAULTS.PORT, 10);
if (isValidPort(port)) {
success(`PORT is valid: ${port}`);
} else {
fail(`PORT is invalid: ${config.PORT}`);
errors++;
}
// TTS
if (config.OPENAI_API_KEY) {
success('OPENAI_API_KEY is set (OpenAI TTS + Whisper enabled)');
} else {
info('OPENAI_API_KEY not set (Edge TTS will be used as fallback)');
}
if (config.REPLICATE_API_TOKEN) {
success('REPLICATE_API_TOKEN is set (Qwen TTS enabled)');
} else {
info('REPLICATE_API_TOKEN not set');
}
// Host binding
const host = config.HOST || DEFAULTS.HOST;
if (host === '0.0.0.0') {
warn('HOST is 0.0.0.0 — server is accessible from the network');
} else {
success(`HOST: ${host}`);
}
// HTTPS certs
if (existsSync(resolve(PROJECT_ROOT, 'certs', 'cert.pem'))) {
success('HTTPS certificates found at certs/');
} else {
info('No HTTPS certificates (HTTP only)');
}
console.log('');
if (errors > 0) {
fail(`${errors} issue(s) found. Run \x1b[36mnpm run setup\x1b[0m to fix.`);
process.exit(1);
} else {
success('Configuration looks good!');
}
console.log('');
}
// ── --defaults mode ──────────────────────────────────────────────────
async function runDefaults(existing: EnvConfig): Promise<void> {
console.log('');
info('Non-interactive mode — using defaults where possible');
console.log('');
const config: EnvConfig = { ...existing };
// Try to auto-detect gateway token
if (!config.GATEWAY_TOKEN) {
const detected = detectGatewayConfig();
const envToken = getEnvGatewayToken();
const token = envToken || detected.token;
if (token) {
config.GATEWAY_TOKEN = token;
success('Auto-detected gateway token');
} else {
fail('GATEWAY_TOKEN is required but could not be auto-detected');
console.log(' Set OPENCLAW_GATEWAY_TOKEN in your environment, or run setup interactively.');
console.log('');
process.exit(1);
}
}
// Apply defaults for everything else
if (!config.GATEWAY_URL) config.GATEWAY_URL = DEFAULTS.GATEWAY_URL;
if (!config.AGENT_NAME) config.AGENT_NAME = DEFAULTS.AGENT_NAME;
if (!config.PORT) config.PORT = DEFAULTS.PORT;
if (!config.HOST) config.HOST = DEFAULTS.HOST;
// Write
if (existsSync(ENV_PATH)) {
const backupPath = backupExistingEnv(ENV_PATH);
info(`Previous config backed up to ${backupPath.replace(PROJECT_ROOT + '/', '')}`);
}
writeEnvFile(ENV_PATH, config);
success('Configuration written to .env');
printSummary(config);
console.log('');
}
// ── Run ──────────────────────────────────────────────────────────────
main().catch((err) => {
// ExitPromptError is thrown when user presses Ctrl+C during a prompt
if (err?.name === 'ExitPromptError') {
cleanupTmp(ENV_PATH);
console.log('\n\n Setup cancelled.\n');
process.exit(130);
}
console.error('\n Setup failed:', err.message || err);
cleanupTmp(ENV_PATH);
process.exit(1);
});