claude-phone-local 2.3.2 → 2.3.4
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/.env.example +22 -0
- package/README.md +10 -1
- package/claude-api-server/package-lock.json +0 -12
- package/claude-api-server/server.js +70 -32
- package/cli/lib/docker.js +411 -411
- package/package.json +1 -1
- package/stt-local/server.py +45 -45
- package/voice-app/DEPLOYMENT.md +2 -0
- package/voice-app/lib/claude-bridge.js +113 -113
package/cli/lib/docker.js
CHANGED
|
@@ -1,411 +1,411 @@
|
|
|
1
|
-
import { spawn, execSync } from 'child_process';
|
|
2
|
-
import crypto from 'crypto';
|
|
3
|
-
import fs from 'fs';
|
|
4
|
-
import path from 'path';
|
|
5
|
-
import {
|
|
6
|
-
getDockerComposePath,
|
|
7
|
-
getEnvPath,
|
|
8
|
-
getConfigDir
|
|
9
|
-
} from './config.js';
|
|
10
|
-
import { getLocalIP } from './utils.js';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Detect which docker compose command to use
|
|
14
|
-
* Some systems have 'docker compose' (plugin), others have 'docker-compose' (standalone)
|
|
15
|
-
* @returns {{cmd: string, args: string[]}} Command and base args for compose
|
|
16
|
-
*/
|
|
17
|
-
function getComposeCommand() {
|
|
18
|
-
// Try 'docker compose' (plugin) first
|
|
19
|
-
try {
|
|
20
|
-
execSync('docker compose version', { stdio: 'pipe' });
|
|
21
|
-
return { cmd: 'docker', args: ['compose'] };
|
|
22
|
-
} catch (e) {
|
|
23
|
-
// Fall back to standalone docker-compose
|
|
24
|
-
try {
|
|
25
|
-
execSync('docker-compose --version', { stdio: 'pipe' });
|
|
26
|
-
return { cmd: 'docker-compose', args: [] };
|
|
27
|
-
} catch (e2) {
|
|
28
|
-
// Default to plugin style, let it fail with helpful error
|
|
29
|
-
return { cmd: 'docker', args: ['compose'] };
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Generate a random secret for Docker services
|
|
36
|
-
* @returns {string} Random 32-character hex string
|
|
37
|
-
*/
|
|
38
|
-
function generateSecret() {
|
|
39
|
-
return crypto.randomBytes(16).toString('hex');
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Check if Docker is installed and running
|
|
44
|
-
* @returns {Promise<{installed: boolean, running: boolean, error?: string}>}
|
|
45
|
-
*/
|
|
46
|
-
export async function checkDocker() {
|
|
47
|
-
// Check if docker command exists
|
|
48
|
-
const installed = await new Promise((resolve) => {
|
|
49
|
-
const check = spawn('docker', ['--version']);
|
|
50
|
-
check.on('close', (code) => resolve(code === 0));
|
|
51
|
-
check.on('error', () => resolve(false));
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
if (!installed) {
|
|
55
|
-
return {
|
|
56
|
-
installed: false,
|
|
57
|
-
running: false,
|
|
58
|
-
error: 'Docker not found. Please install Docker from https://docs.docker.com/engine/install/'
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Check if Docker daemon is running by running a simple command
|
|
63
|
-
const running = await new Promise((resolve) => {
|
|
64
|
-
const check = spawn('docker', ['ps', '-q'], {
|
|
65
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
66
|
-
});
|
|
67
|
-
check.on('close', (code) => resolve(code === 0));
|
|
68
|
-
check.on('error', () => resolve(false));
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
if (!running) {
|
|
72
|
-
return {
|
|
73
|
-
installed: true,
|
|
74
|
-
running: false,
|
|
75
|
-
error: 'Docker is installed but not running. Please start Docker Desktop.'
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return {
|
|
80
|
-
installed: true,
|
|
81
|
-
running: true
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Generate docker-compose.yml from config
|
|
87
|
-
* @param {object} config - Configuration object
|
|
88
|
-
* @returns {string} Docker compose YAML content
|
|
89
|
-
*/
|
|
90
|
-
export function generateDockerCompose(config) {
|
|
91
|
-
const drachtioPort = config.deployment?.pi?.drachtioPort || config.sip?.drachtioSipPort || 5070;
|
|
92
|
-
const rtpStart = config.server.rtpStart || 30000;
|
|
93
|
-
const rtpEnd = config.server.rtpEnd || 30100;
|
|
94
|
-
|
|
95
|
-
// This file is written to ~/.claude-phone/ (getDockerComposePath()), which
|
|
96
|
-
// has no Dockerfile or source of its own - those live in the installed npm
|
|
97
|
-
// package directory (config.paths.voiceApp's parent). `build: .` would try
|
|
98
|
-
// to build from ~/.claude-phone and fail (or silently reuse a stale cached
|
|
99
|
-
// image tag from an unrelated build, masking the failure) on any machine
|
|
100
|
-
// without that image already present.
|
|
101
|
-
const projectRoot = config.paths?.voiceApp
|
|
102
|
-
? path.dirname(config.paths.voiceApp)
|
|
103
|
-
: '.';
|
|
104
|
-
|
|
105
|
-
// Single image: drachtio + FreeSWITCH + voice-app + STT + TTS under supervisord.
|
|
106
|
-
// They talk over 127.0.0.1, so there is no container DNS to misconfigure.
|
|
107
|
-
// Everything that must outlive a restart is on the ./data bind mount.
|
|
108
|
-
//
|
|
109
|
-
// EXTERNAL_IP is intentionally NOT set under `environment:` here - it comes
|
|
110
|
-
// from `.env` via env_file below. An explicit `environment:` entry would
|
|
111
|
-
// override env_file, so editing .env after a network change (a new LAN IP,
|
|
112
|
-
// a different office) would silently do nothing.
|
|
113
|
-
return `# Generated by \`claude-phone setup\` - edit .env, not this file.
|
|
114
|
-
services:
|
|
115
|
-
claude-phone:
|
|
116
|
-
build: "${projectRoot.replace(/\\/g, '/')}"
|
|
117
|
-
image: claude-phone-local:latest
|
|
118
|
-
container_name: claude-phone
|
|
119
|
-
restart: unless-stopped
|
|
120
|
-
env_file:
|
|
121
|
-
- .env
|
|
122
|
-
environment:
|
|
123
|
-
- DRACHTIO_HOST=127.0.0.1
|
|
124
|
-
- FREESWITCH_HOST=127.0.0.1
|
|
125
|
-
- STT_LOCAL_URL=http://127.0.0.1:9001
|
|
126
|
-
- TTS_LOCAL_URL=http://127.0.0.1:9002
|
|
127
|
-
- AUDIO_BASE_URL=http://127.0.0.1:3000
|
|
128
|
-
- RTP_START=${rtpStart}
|
|
129
|
-
- RTP_END=${rtpEnd}
|
|
130
|
-
ports:
|
|
131
|
-
- "3000:3000"
|
|
132
|
-
- "${drachtioPort}:${drachtioPort}/udp"
|
|
133
|
-
- "${drachtioPort}:${drachtioPort}/tcp"
|
|
134
|
-
- "${rtpStart}-${rtpEnd}:${rtpStart}-${rtpEnd}/udp"
|
|
135
|
-
volumes:
|
|
136
|
-
- ./data:/data
|
|
137
|
-
`;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Generate .env file from config
|
|
142
|
-
* @param {object} config - Configuration object
|
|
143
|
-
* @returns {string} Environment file content
|
|
144
|
-
*/
|
|
145
|
-
export function generateEnvFile(config) {
|
|
146
|
-
// Ensure secrets exist in config
|
|
147
|
-
if (!config.secrets) {
|
|
148
|
-
config.secrets = {
|
|
149
|
-
drachtio: generateSecret(),
|
|
150
|
-
freeswitch: generateSecret()
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// 'auto' means re-detect the current LAN IP every time this is written
|
|
155
|
-
// (i.e. every `claude-phone start`), instead of using whatever IP was
|
|
156
|
-
// current the last time setup ran. A saved concrete IP goes stale the
|
|
157
|
-
// moment the machine moves networks (home <-> office, DHCP renewal) and
|
|
158
|
-
// nothing here writes to it again until setup is manually re-run.
|
|
159
|
-
const resolvedExternalIp = config.server.externalIp === 'auto'
|
|
160
|
-
? getLocalIP()
|
|
161
|
-
: config.server.externalIp;
|
|
162
|
-
|
|
163
|
-
// Determine Claude API URL based on deployment mode
|
|
164
|
-
let claudeApiUrl;
|
|
165
|
-
if (config.deployment && config.deployment.mode === 'pi-split' && config.deployment.pi && config.deployment.pi.macIp) {
|
|
166
|
-
// Pi mode: point to remote API server
|
|
167
|
-
claudeApiUrl = `http://${config.deployment.pi.macIp}:${config.server.claudeApiPort}`;
|
|
168
|
-
} else if (config.deployment && config.deployment.mode === 'voice-server' && config.deployment.apiServerIp) {
|
|
169
|
-
// Voice server mode (non-Pi): point to remote API server
|
|
170
|
-
claudeApiUrl = `http://${config.deployment.apiServerIp}:${config.server.claudeApiPort}`;
|
|
171
|
-
} else {
|
|
172
|
-
// Both or api-server mode: local API server runs on the HOST (it shells
|
|
173
|
-
// out to the `claude` CLI directly), while voice-app runs in Docker on
|
|
174
|
-
// a bridge network — so it must reach the host via host.docker.internal,
|
|
175
|
-
// not localhost (which inside the container means the container itself).
|
|
176
|
-
claudeApiUrl = `http://host.docker.internal:${config.server.claudeApiPort}`;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const lines = [
|
|
180
|
-
'# ====================================',
|
|
181
|
-
'# WARNING: DO NOT SHARE THIS FILE',
|
|
182
|
-
'# Contains API keys and passwords',
|
|
183
|
-
'# ====================================',
|
|
184
|
-
'# Claude Phone Configuration',
|
|
185
|
-
'# Generated by claude-phone CLI',
|
|
186
|
-
'# ====================================',
|
|
187
|
-
'',
|
|
188
|
-
'# Network Configuration',
|
|
189
|
-
`EXTERNAL_IP=${resolvedExternalIp}`,
|
|
190
|
-
'',
|
|
191
|
-
'# Drachtio Configuration',
|
|
192
|
-
'DRACHTIO_HOST=drachtio',
|
|
193
|
-
'DRACHTIO_PORT=9022',
|
|
194
|
-
`DRACHTIO_SECRET=${config.secrets.drachtio}`,
|
|
195
|
-
// SIP port for Contact header (5070 when 3CX SBC is present, 5060 otherwise)
|
|
196
|
-
`DRACHTIO_SIP_PORT=${config.sip?.drachtioSipPort || config.deployment?.pi?.drachtioPort || 5070}`,
|
|
197
|
-
'',
|
|
198
|
-
'# FreeSWITCH Configuration',
|
|
199
|
-
'FREESWITCH_HOST=freeswitch',
|
|
200
|
-
'FREESWITCH_PORT=8021',
|
|
201
|
-
// Note: This is the default ESL password for drachtio/drachtio-freeswitch-mrf
|
|
202
|
-
'FREESWITCH_SECRET=JambonzR0ck$',
|
|
203
|
-
'',
|
|
204
|
-
'# 3CX / SIP Configuration',
|
|
205
|
-
`SIP_DOMAIN=${config.sip.domain}`,
|
|
206
|
-
// 'auto' means the 3CX SBC runs on this same PC, so its address is
|
|
207
|
-
// whatever this machine's current LAN IP is - re-resolved every start,
|
|
208
|
-
// same reasoning as EXTERNAL_IP above.
|
|
209
|
-
`SIP_REGISTRAR=${config.sip.registrar === 'auto' ? resolvedExternalIp : config.sip.registrar}`,
|
|
210
|
-
'',
|
|
211
|
-
'# Default extension (primary device)',
|
|
212
|
-
`SIP_EXTENSION=${config.devices[0].extension}`,
|
|
213
|
-
`SIP_AUTH_ID=${config.devices[0].authId}`,
|
|
214
|
-
`SIP_PASSWORD=${config.devices[0].password}`,
|
|
215
|
-
'',
|
|
216
|
-
'# Claude API Server',
|
|
217
|
-
`CLAUDE_API_URL=${claudeApiUrl}`,
|
|
218
|
-
'',
|
|
219
|
-
...(config.api.mode === 'local' ? [
|
|
220
|
-
'# Local STT/TTS (no API keys — faster-whisper + Piper containers)',
|
|
221
|
-
'STT_MODE=local',
|
|
222
|
-
'TTS_MODE=local',
|
|
223
|
-
`STT_LOCAL_URL=${config.api.local?.sttUrl || 'http://stt-local:9001'}`,
|
|
224
|
-
`TTS_LOCAL_URL=${config.api.local?.ttsUrl || 'http://tts-local:9002'}`,
|
|
225
|
-
`AUDIO_BASE_URL=${config.api.local?.audioBaseUrl || 'http://voice-app:3000'}`,
|
|
226
|
-
`PIPER_VOICE=${config.devices[0].voice || config.api.local?.voice || 'en_US-lessac-medium'}`,
|
|
227
|
-
`WHISPER_MODEL=${config.api.local?.whisperModel || 'small'}`,
|
|
228
|
-
''
|
|
229
|
-
] : [
|
|
230
|
-
'# STT/TTS mode',
|
|
231
|
-
'STT_MODE=cloud',
|
|
232
|
-
'TTS_MODE=cloud',
|
|
233
|
-
'',
|
|
234
|
-
'# ElevenLabs TTS',
|
|
235
|
-
`ELEVENLABS_API_KEY=${config.api.elevenlabs.apiKey}`,
|
|
236
|
-
`ELEVENLABS_VOICE_ID=${config.devices[0].voiceId}`,
|
|
237
|
-
'',
|
|
238
|
-
'# OpenAI (Whisper STT)',
|
|
239
|
-
`OPENAI_API_KEY=${config.api.openai.apiKey}`,
|
|
240
|
-
''
|
|
241
|
-
]),
|
|
242
|
-
'# Application Settings',
|
|
243
|
-
`HTTP_PORT=${config.server.httpPort}`,
|
|
244
|
-
'WS_PORT=3001',
|
|
245
|
-
'AUDIO_DIR=/app/audio',
|
|
246
|
-
'',
|
|
247
|
-
'# Outbound Call Settings',
|
|
248
|
-
'MAX_CONVERSATION_TURNS=10',
|
|
249
|
-
'OUTBOUND_RING_TIMEOUT=30',
|
|
250
|
-
''
|
|
251
|
-
];
|
|
252
|
-
|
|
253
|
-
return lines.join('\n');
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Write Docker configuration files
|
|
258
|
-
* @param {object} config - Configuration object
|
|
259
|
-
* @returns {Promise<void>}
|
|
260
|
-
*/
|
|
261
|
-
export async function writeDockerConfig(config) {
|
|
262
|
-
const dockerComposePath = getDockerComposePath();
|
|
263
|
-
const envPath = getEnvPath();
|
|
264
|
-
|
|
265
|
-
const dockerComposeContent = generateDockerCompose(config);
|
|
266
|
-
const envContent = generateEnvFile(config);
|
|
267
|
-
|
|
268
|
-
await fs.promises.writeFile(dockerComposePath, dockerComposeContent, { mode: 0o644 });
|
|
269
|
-
await fs.promises.writeFile(envPath, envContent, { mode: 0o600 });
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Start Docker containers
|
|
274
|
-
* @returns {Promise<void>}
|
|
275
|
-
*/
|
|
276
|
-
export async function startContainers() {
|
|
277
|
-
const configDir = getConfigDir();
|
|
278
|
-
const dockerComposePath = getDockerComposePath();
|
|
279
|
-
|
|
280
|
-
if (!fs.existsSync(dockerComposePath)) {
|
|
281
|
-
throw new Error('Docker configuration not found. Run "claude-phone setup" first.');
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const compose = getComposeCommand();
|
|
285
|
-
const composeArgs = [...compose.args, '-f', dockerComposePath, 'up', '-d'];
|
|
286
|
-
|
|
287
|
-
return new Promise((resolve, reject) => {
|
|
288
|
-
const child = spawn(compose.cmd, composeArgs, {
|
|
289
|
-
cwd: configDir,
|
|
290
|
-
stdio: 'pipe'
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
let output = '';
|
|
294
|
-
child.stdout.on('data', (data) => {
|
|
295
|
-
output += data.toString();
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
child.stderr.on('data', (data) => {
|
|
299
|
-
output += data.toString();
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
child.on('close', (code) => {
|
|
303
|
-
if (code === 0) {
|
|
304
|
-
resolve();
|
|
305
|
-
} else {
|
|
306
|
-
// AC22: Detect ARM64 image pull failure
|
|
307
|
-
if (output.includes('no matching manifest') ||
|
|
308
|
-
output.includes('image with reference') && output.includes('arm64')) {
|
|
309
|
-
const error = new Error(
|
|
310
|
-
'ARM64 Docker image pull failed.\n\n' +
|
|
311
|
-
'Try manually pulling images:\n' +
|
|
312
|
-
' docker pull drachtio/drachtio-server:latest\n' +
|
|
313
|
-
' docker pull drachtio/drachtio-freeswitch-mrf:latest\n\n' +
|
|
314
|
-
'If images are not available for ARM64, you may need to build them locally.'
|
|
315
|
-
);
|
|
316
|
-
reject(error);
|
|
317
|
-
} else {
|
|
318
|
-
reject(new Error(`Docker compose failed (exit ${code}): ${output}`));
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Stop Docker containers
|
|
327
|
-
* @returns {Promise<void>}
|
|
328
|
-
*/
|
|
329
|
-
export async function stopContainers() {
|
|
330
|
-
const configDir = getConfigDir();
|
|
331
|
-
const dockerComposePath = getDockerComposePath();
|
|
332
|
-
|
|
333
|
-
if (!fs.existsSync(dockerComposePath)) {
|
|
334
|
-
// No containers to stop
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const compose = getComposeCommand();
|
|
339
|
-
const composeArgs = [...compose.args, '-f', dockerComposePath, 'down'];
|
|
340
|
-
|
|
341
|
-
return new Promise((resolve, reject) => {
|
|
342
|
-
const child = spawn(compose.cmd, composeArgs, {
|
|
343
|
-
cwd: configDir,
|
|
344
|
-
stdio: 'pipe'
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
let output = '';
|
|
348
|
-
child.stdout.on('data', (data) => {
|
|
349
|
-
output += data.toString();
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
child.stderr.on('data', (data) => {
|
|
353
|
-
output += data.toString();
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
child.on('close', (code) => {
|
|
357
|
-
if (code === 0) {
|
|
358
|
-
resolve();
|
|
359
|
-
} else {
|
|
360
|
-
reject(new Error(`Docker compose down failed (exit ${code}): ${output}`));
|
|
361
|
-
}
|
|
362
|
-
});
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Get status of Docker containers
|
|
368
|
-
* @returns {Promise<Array<{name: string, status: string}>>}
|
|
369
|
-
*/
|
|
370
|
-
export async function getContainerStatus() {
|
|
371
|
-
const dockerComposePath = getDockerComposePath();
|
|
372
|
-
|
|
373
|
-
if (!fs.existsSync(dockerComposePath)) {
|
|
374
|
-
return [];
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const compose = getComposeCommand();
|
|
378
|
-
const composeArgs = [...compose.args, '-f', dockerComposePath, 'ps', '--format', 'json'];
|
|
379
|
-
|
|
380
|
-
return new Promise((resolve) => {
|
|
381
|
-
const child = spawn(compose.cmd, composeArgs, {
|
|
382
|
-
stdio: 'pipe'
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
let output = '';
|
|
386
|
-
child.stdout.on('data', (data) => {
|
|
387
|
-
output += data.toString();
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
child.on('close', (code) => {
|
|
391
|
-
if (code === 0) {
|
|
392
|
-
try {
|
|
393
|
-
// Parse JSON lines (one per container)
|
|
394
|
-
const lines = output.trim().split('\n').filter(l => l);
|
|
395
|
-
const containers = lines.map(line => {
|
|
396
|
-
const data = JSON.parse(line);
|
|
397
|
-
return {
|
|
398
|
-
name: data.Name || data.Service,
|
|
399
|
-
status: data.State || data.Status
|
|
400
|
-
};
|
|
401
|
-
});
|
|
402
|
-
resolve(containers);
|
|
403
|
-
} catch (error) {
|
|
404
|
-
resolve([]);
|
|
405
|
-
}
|
|
406
|
-
} else {
|
|
407
|
-
resolve([]);
|
|
408
|
-
}
|
|
409
|
-
});
|
|
410
|
-
});
|
|
411
|
-
}
|
|
1
|
+
import { spawn, execSync } from 'child_process';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import {
|
|
6
|
+
getDockerComposePath,
|
|
7
|
+
getEnvPath,
|
|
8
|
+
getConfigDir
|
|
9
|
+
} from './config.js';
|
|
10
|
+
import { getLocalIP } from './utils.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Detect which docker compose command to use
|
|
14
|
+
* Some systems have 'docker compose' (plugin), others have 'docker-compose' (standalone)
|
|
15
|
+
* @returns {{cmd: string, args: string[]}} Command and base args for compose
|
|
16
|
+
*/
|
|
17
|
+
function getComposeCommand() {
|
|
18
|
+
// Try 'docker compose' (plugin) first
|
|
19
|
+
try {
|
|
20
|
+
execSync('docker compose version', { stdio: 'pipe' });
|
|
21
|
+
return { cmd: 'docker', args: ['compose'] };
|
|
22
|
+
} catch (e) {
|
|
23
|
+
// Fall back to standalone docker-compose
|
|
24
|
+
try {
|
|
25
|
+
execSync('docker-compose --version', { stdio: 'pipe' });
|
|
26
|
+
return { cmd: 'docker-compose', args: [] };
|
|
27
|
+
} catch (e2) {
|
|
28
|
+
// Default to plugin style, let it fail with helpful error
|
|
29
|
+
return { cmd: 'docker', args: ['compose'] };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generate a random secret for Docker services
|
|
36
|
+
* @returns {string} Random 32-character hex string
|
|
37
|
+
*/
|
|
38
|
+
function generateSecret() {
|
|
39
|
+
return crypto.randomBytes(16).toString('hex');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if Docker is installed and running
|
|
44
|
+
* @returns {Promise<{installed: boolean, running: boolean, error?: string}>}
|
|
45
|
+
*/
|
|
46
|
+
export async function checkDocker() {
|
|
47
|
+
// Check if docker command exists
|
|
48
|
+
const installed = await new Promise((resolve) => {
|
|
49
|
+
const check = spawn('docker', ['--version']);
|
|
50
|
+
check.on('close', (code) => resolve(code === 0));
|
|
51
|
+
check.on('error', () => resolve(false));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!installed) {
|
|
55
|
+
return {
|
|
56
|
+
installed: false,
|
|
57
|
+
running: false,
|
|
58
|
+
error: 'Docker not found. Please install Docker from https://docs.docker.com/engine/install/'
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check if Docker daemon is running by running a simple command
|
|
63
|
+
const running = await new Promise((resolve) => {
|
|
64
|
+
const check = spawn('docker', ['ps', '-q'], {
|
|
65
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
66
|
+
});
|
|
67
|
+
check.on('close', (code) => resolve(code === 0));
|
|
68
|
+
check.on('error', () => resolve(false));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!running) {
|
|
72
|
+
return {
|
|
73
|
+
installed: true,
|
|
74
|
+
running: false,
|
|
75
|
+
error: 'Docker is installed but not running. Please start Docker Desktop.'
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
installed: true,
|
|
81
|
+
running: true
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Generate docker-compose.yml from config
|
|
87
|
+
* @param {object} config - Configuration object
|
|
88
|
+
* @returns {string} Docker compose YAML content
|
|
89
|
+
*/
|
|
90
|
+
export function generateDockerCompose(config) {
|
|
91
|
+
const drachtioPort = config.deployment?.pi?.drachtioPort || config.sip?.drachtioSipPort || 5070;
|
|
92
|
+
const rtpStart = config.server.rtpStart || 30000;
|
|
93
|
+
const rtpEnd = config.server.rtpEnd || 30100;
|
|
94
|
+
|
|
95
|
+
// This file is written to ~/.claude-phone/ (getDockerComposePath()), which
|
|
96
|
+
// has no Dockerfile or source of its own - those live in the installed npm
|
|
97
|
+
// package directory (config.paths.voiceApp's parent). `build: .` would try
|
|
98
|
+
// to build from ~/.claude-phone and fail (or silently reuse a stale cached
|
|
99
|
+
// image tag from an unrelated build, masking the failure) on any machine
|
|
100
|
+
// without that image already present.
|
|
101
|
+
const projectRoot = config.paths?.voiceApp
|
|
102
|
+
? path.dirname(config.paths.voiceApp)
|
|
103
|
+
: '.';
|
|
104
|
+
|
|
105
|
+
// Single image: drachtio + FreeSWITCH + voice-app + STT + TTS under supervisord.
|
|
106
|
+
// They talk over 127.0.0.1, so there is no container DNS to misconfigure.
|
|
107
|
+
// Everything that must outlive a restart is on the ./data bind mount.
|
|
108
|
+
//
|
|
109
|
+
// EXTERNAL_IP is intentionally NOT set under `environment:` here - it comes
|
|
110
|
+
// from `.env` via env_file below. An explicit `environment:` entry would
|
|
111
|
+
// override env_file, so editing .env after a network change (a new LAN IP,
|
|
112
|
+
// a different office) would silently do nothing.
|
|
113
|
+
return `# Generated by \`claude-phone setup\` - edit .env, not this file.
|
|
114
|
+
services:
|
|
115
|
+
claude-phone:
|
|
116
|
+
build: "${projectRoot.replace(/\\/g, '/')}"
|
|
117
|
+
image: claude-phone-local:latest
|
|
118
|
+
container_name: claude-phone
|
|
119
|
+
restart: unless-stopped
|
|
120
|
+
env_file:
|
|
121
|
+
- .env
|
|
122
|
+
environment:
|
|
123
|
+
- DRACHTIO_HOST=127.0.0.1
|
|
124
|
+
- FREESWITCH_HOST=127.0.0.1
|
|
125
|
+
- STT_LOCAL_URL=http://127.0.0.1:9001
|
|
126
|
+
- TTS_LOCAL_URL=http://127.0.0.1:9002
|
|
127
|
+
- AUDIO_BASE_URL=http://127.0.0.1:3000
|
|
128
|
+
- RTP_START=${rtpStart}
|
|
129
|
+
- RTP_END=${rtpEnd}
|
|
130
|
+
ports:
|
|
131
|
+
- "3000:3000"
|
|
132
|
+
- "${drachtioPort}:${drachtioPort}/udp"
|
|
133
|
+
- "${drachtioPort}:${drachtioPort}/tcp"
|
|
134
|
+
- "${rtpStart}-${rtpEnd}:${rtpStart}-${rtpEnd}/udp"
|
|
135
|
+
volumes:
|
|
136
|
+
- ./data:/data
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Generate .env file from config
|
|
142
|
+
* @param {object} config - Configuration object
|
|
143
|
+
* @returns {string} Environment file content
|
|
144
|
+
*/
|
|
145
|
+
export function generateEnvFile(config) {
|
|
146
|
+
// Ensure secrets exist in config
|
|
147
|
+
if (!config.secrets) {
|
|
148
|
+
config.secrets = {
|
|
149
|
+
drachtio: generateSecret(),
|
|
150
|
+
freeswitch: generateSecret()
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 'auto' means re-detect the current LAN IP every time this is written
|
|
155
|
+
// (i.e. every `claude-phone start`), instead of using whatever IP was
|
|
156
|
+
// current the last time setup ran. A saved concrete IP goes stale the
|
|
157
|
+
// moment the machine moves networks (home <-> office, DHCP renewal) and
|
|
158
|
+
// nothing here writes to it again until setup is manually re-run.
|
|
159
|
+
const resolvedExternalIp = config.server.externalIp === 'auto'
|
|
160
|
+
? getLocalIP()
|
|
161
|
+
: config.server.externalIp;
|
|
162
|
+
|
|
163
|
+
// Determine Claude API URL based on deployment mode
|
|
164
|
+
let claudeApiUrl;
|
|
165
|
+
if (config.deployment && config.deployment.mode === 'pi-split' && config.deployment.pi && config.deployment.pi.macIp) {
|
|
166
|
+
// Pi mode: point to remote API server
|
|
167
|
+
claudeApiUrl = `http://${config.deployment.pi.macIp}:${config.server.claudeApiPort}`;
|
|
168
|
+
} else if (config.deployment && config.deployment.mode === 'voice-server' && config.deployment.apiServerIp) {
|
|
169
|
+
// Voice server mode (non-Pi): point to remote API server
|
|
170
|
+
claudeApiUrl = `http://${config.deployment.apiServerIp}:${config.server.claudeApiPort}`;
|
|
171
|
+
} else {
|
|
172
|
+
// Both or api-server mode: local API server runs on the HOST (it shells
|
|
173
|
+
// out to the `claude` CLI directly), while voice-app runs in Docker on
|
|
174
|
+
// a bridge network — so it must reach the host via host.docker.internal,
|
|
175
|
+
// not localhost (which inside the container means the container itself).
|
|
176
|
+
claudeApiUrl = `http://host.docker.internal:${config.server.claudeApiPort}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const lines = [
|
|
180
|
+
'# ====================================',
|
|
181
|
+
'# WARNING: DO NOT SHARE THIS FILE',
|
|
182
|
+
'# Contains API keys and passwords',
|
|
183
|
+
'# ====================================',
|
|
184
|
+
'# Claude Phone Configuration',
|
|
185
|
+
'# Generated by claude-phone CLI',
|
|
186
|
+
'# ====================================',
|
|
187
|
+
'',
|
|
188
|
+
'# Network Configuration',
|
|
189
|
+
`EXTERNAL_IP=${resolvedExternalIp}`,
|
|
190
|
+
'',
|
|
191
|
+
'# Drachtio Configuration',
|
|
192
|
+
'DRACHTIO_HOST=drachtio',
|
|
193
|
+
'DRACHTIO_PORT=9022',
|
|
194
|
+
`DRACHTIO_SECRET=${config.secrets.drachtio}`,
|
|
195
|
+
// SIP port for Contact header (5070 when 3CX SBC is present, 5060 otherwise)
|
|
196
|
+
`DRACHTIO_SIP_PORT=${config.sip?.drachtioSipPort || config.deployment?.pi?.drachtioPort || 5070}`,
|
|
197
|
+
'',
|
|
198
|
+
'# FreeSWITCH Configuration',
|
|
199
|
+
'FREESWITCH_HOST=freeswitch',
|
|
200
|
+
'FREESWITCH_PORT=8021',
|
|
201
|
+
// Note: This is the default ESL password for drachtio/drachtio-freeswitch-mrf
|
|
202
|
+
'FREESWITCH_SECRET=JambonzR0ck$',
|
|
203
|
+
'',
|
|
204
|
+
'# 3CX / SIP Configuration',
|
|
205
|
+
`SIP_DOMAIN=${config.sip.domain}`,
|
|
206
|
+
// 'auto' means the 3CX SBC runs on this same PC, so its address is
|
|
207
|
+
// whatever this machine's current LAN IP is - re-resolved every start,
|
|
208
|
+
// same reasoning as EXTERNAL_IP above.
|
|
209
|
+
`SIP_REGISTRAR=${config.sip.registrar === 'auto' ? resolvedExternalIp : config.sip.registrar}`,
|
|
210
|
+
'',
|
|
211
|
+
'# Default extension (primary device)',
|
|
212
|
+
`SIP_EXTENSION=${config.devices[0].extension}`,
|
|
213
|
+
`SIP_AUTH_ID=${config.devices[0].authId}`,
|
|
214
|
+
`SIP_PASSWORD=${config.devices[0].password}`,
|
|
215
|
+
'',
|
|
216
|
+
'# Claude API Server',
|
|
217
|
+
`CLAUDE_API_URL=${claudeApiUrl}`,
|
|
218
|
+
'',
|
|
219
|
+
...(config.api.mode === 'local' ? [
|
|
220
|
+
'# Local STT/TTS (no API keys — faster-whisper + Piper containers)',
|
|
221
|
+
'STT_MODE=local',
|
|
222
|
+
'TTS_MODE=local',
|
|
223
|
+
`STT_LOCAL_URL=${config.api.local?.sttUrl || 'http://stt-local:9001'}`,
|
|
224
|
+
`TTS_LOCAL_URL=${config.api.local?.ttsUrl || 'http://tts-local:9002'}`,
|
|
225
|
+
`AUDIO_BASE_URL=${config.api.local?.audioBaseUrl || 'http://voice-app:3000'}`,
|
|
226
|
+
`PIPER_VOICE=${config.devices[0].voice || config.api.local?.voice || 'en_US-lessac-medium'}`,
|
|
227
|
+
`WHISPER_MODEL=${config.api.local?.whisperModel || 'small'}`,
|
|
228
|
+
''
|
|
229
|
+
] : [
|
|
230
|
+
'# STT/TTS mode',
|
|
231
|
+
'STT_MODE=cloud',
|
|
232
|
+
'TTS_MODE=cloud',
|
|
233
|
+
'',
|
|
234
|
+
'# ElevenLabs TTS',
|
|
235
|
+
`ELEVENLABS_API_KEY=${config.api.elevenlabs.apiKey}`,
|
|
236
|
+
`ELEVENLABS_VOICE_ID=${config.devices[0].voiceId}`,
|
|
237
|
+
'',
|
|
238
|
+
'# OpenAI (Whisper STT)',
|
|
239
|
+
`OPENAI_API_KEY=${config.api.openai.apiKey}`,
|
|
240
|
+
''
|
|
241
|
+
]),
|
|
242
|
+
'# Application Settings',
|
|
243
|
+
`HTTP_PORT=${config.server.httpPort}`,
|
|
244
|
+
'WS_PORT=3001',
|
|
245
|
+
'AUDIO_DIR=/app/audio',
|
|
246
|
+
'',
|
|
247
|
+
'# Outbound Call Settings',
|
|
248
|
+
'MAX_CONVERSATION_TURNS=10',
|
|
249
|
+
'OUTBOUND_RING_TIMEOUT=30',
|
|
250
|
+
''
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
return lines.join('\n');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Write Docker configuration files
|
|
258
|
+
* @param {object} config - Configuration object
|
|
259
|
+
* @returns {Promise<void>}
|
|
260
|
+
*/
|
|
261
|
+
export async function writeDockerConfig(config) {
|
|
262
|
+
const dockerComposePath = getDockerComposePath();
|
|
263
|
+
const envPath = getEnvPath();
|
|
264
|
+
|
|
265
|
+
const dockerComposeContent = generateDockerCompose(config);
|
|
266
|
+
const envContent = generateEnvFile(config);
|
|
267
|
+
|
|
268
|
+
await fs.promises.writeFile(dockerComposePath, dockerComposeContent, { mode: 0o644 });
|
|
269
|
+
await fs.promises.writeFile(envPath, envContent, { mode: 0o600 });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Start Docker containers
|
|
274
|
+
* @returns {Promise<void>}
|
|
275
|
+
*/
|
|
276
|
+
export async function startContainers() {
|
|
277
|
+
const configDir = getConfigDir();
|
|
278
|
+
const dockerComposePath = getDockerComposePath();
|
|
279
|
+
|
|
280
|
+
if (!fs.existsSync(dockerComposePath)) {
|
|
281
|
+
throw new Error('Docker configuration not found. Run "claude-phone setup" first.');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const compose = getComposeCommand();
|
|
285
|
+
const composeArgs = [...compose.args, '-f', dockerComposePath, 'up', '-d'];
|
|
286
|
+
|
|
287
|
+
return new Promise((resolve, reject) => {
|
|
288
|
+
const child = spawn(compose.cmd, composeArgs, {
|
|
289
|
+
cwd: configDir,
|
|
290
|
+
stdio: 'pipe'
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
let output = '';
|
|
294
|
+
child.stdout.on('data', (data) => {
|
|
295
|
+
output += data.toString();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
child.stderr.on('data', (data) => {
|
|
299
|
+
output += data.toString();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
child.on('close', (code) => {
|
|
303
|
+
if (code === 0) {
|
|
304
|
+
resolve();
|
|
305
|
+
} else {
|
|
306
|
+
// AC22: Detect ARM64 image pull failure
|
|
307
|
+
if (output.includes('no matching manifest') ||
|
|
308
|
+
output.includes('image with reference') && output.includes('arm64')) {
|
|
309
|
+
const error = new Error(
|
|
310
|
+
'ARM64 Docker image pull failed.\n\n' +
|
|
311
|
+
'Try manually pulling images:\n' +
|
|
312
|
+
' docker pull drachtio/drachtio-server:latest\n' +
|
|
313
|
+
' docker pull drachtio/drachtio-freeswitch-mrf:latest\n\n' +
|
|
314
|
+
'If images are not available for ARM64, you may need to build them locally.'
|
|
315
|
+
);
|
|
316
|
+
reject(error);
|
|
317
|
+
} else {
|
|
318
|
+
reject(new Error(`Docker compose failed (exit ${code}): ${output}`));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Stop Docker containers
|
|
327
|
+
* @returns {Promise<void>}
|
|
328
|
+
*/
|
|
329
|
+
export async function stopContainers() {
|
|
330
|
+
const configDir = getConfigDir();
|
|
331
|
+
const dockerComposePath = getDockerComposePath();
|
|
332
|
+
|
|
333
|
+
if (!fs.existsSync(dockerComposePath)) {
|
|
334
|
+
// No containers to stop
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const compose = getComposeCommand();
|
|
339
|
+
const composeArgs = [...compose.args, '-f', dockerComposePath, 'down'];
|
|
340
|
+
|
|
341
|
+
return new Promise((resolve, reject) => {
|
|
342
|
+
const child = spawn(compose.cmd, composeArgs, {
|
|
343
|
+
cwd: configDir,
|
|
344
|
+
stdio: 'pipe'
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
let output = '';
|
|
348
|
+
child.stdout.on('data', (data) => {
|
|
349
|
+
output += data.toString();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
child.stderr.on('data', (data) => {
|
|
353
|
+
output += data.toString();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
child.on('close', (code) => {
|
|
357
|
+
if (code === 0) {
|
|
358
|
+
resolve();
|
|
359
|
+
} else {
|
|
360
|
+
reject(new Error(`Docker compose down failed (exit ${code}): ${output}`));
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Get status of Docker containers
|
|
368
|
+
* @returns {Promise<Array<{name: string, status: string}>>}
|
|
369
|
+
*/
|
|
370
|
+
export async function getContainerStatus() {
|
|
371
|
+
const dockerComposePath = getDockerComposePath();
|
|
372
|
+
|
|
373
|
+
if (!fs.existsSync(dockerComposePath)) {
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const compose = getComposeCommand();
|
|
378
|
+
const composeArgs = [...compose.args, '-f', dockerComposePath, 'ps', '--format', 'json'];
|
|
379
|
+
|
|
380
|
+
return new Promise((resolve) => {
|
|
381
|
+
const child = spawn(compose.cmd, composeArgs, {
|
|
382
|
+
stdio: 'pipe'
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
let output = '';
|
|
386
|
+
child.stdout.on('data', (data) => {
|
|
387
|
+
output += data.toString();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
child.on('close', (code) => {
|
|
391
|
+
if (code === 0) {
|
|
392
|
+
try {
|
|
393
|
+
// Parse JSON lines (one per container)
|
|
394
|
+
const lines = output.trim().split('\n').filter(l => l);
|
|
395
|
+
const containers = lines.map(line => {
|
|
396
|
+
const data = JSON.parse(line);
|
|
397
|
+
return {
|
|
398
|
+
name: data.Name || data.Service,
|
|
399
|
+
status: data.State || data.Status
|
|
400
|
+
};
|
|
401
|
+
});
|
|
402
|
+
resolve(containers);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
resolve([]);
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
resolve([]);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
}
|