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/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
+ }