voicecc 1.1.7 → 1.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/voicecc.js CHANGED
@@ -3,30 +3,47 @@
3
3
  /**
4
4
  * CLI entry point for the voicecc command.
5
5
  *
6
+ * Responsibilities:
6
7
  * - On first run (no .env), launches an interactive setup wizard
7
8
  * - Copies CLAUDE.md template on first run
8
- * - Spawns the dashboard server
9
+ * - Manages the server as a background daemon (start/stop/status)
10
+ * - Supports subcommands: stop, logs, autostart
9
11
  */
10
12
 
11
13
  import { spawn, execSync } from "node:child_process";
12
- import { copyFileSync, existsSync, chownSync, mkdirSync } from "node:fs";
13
- import { writeFile, readFile } from "node:fs/promises";
14
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, openSync, closeSync } from "node:fs";
15
+ import { writeFile } from "node:fs/promises";
14
16
  import { createInterface } from "node:readline";
15
17
  import { randomBytes } from "node:crypto";
16
18
  import { dirname, join } from "node:path";
17
19
  import { fileURLToPath } from "node:url";
20
+ import { homedir, platform } from "node:os";
18
21
 
19
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
23
  const PKG_ROOT = join(__dirname, "..");
21
24
  const TSX_BIN = join(PKG_ROOT, "node_modules", ".bin", "tsx");
22
25
  const ENV_PATH = join(PKG_ROOT, ".env");
23
26
 
27
+ const VOICECC_DIR = join(homedir(), ".voicecc");
28
+ const PID_FILE = join(VOICECC_DIR, "voicecc.pid");
29
+ const LOG_FILE = join(VOICECC_DIR, "voicecc.log");
30
+ const STATUS_FILE = join(VOICECC_DIR, "status.json");
31
+
24
32
  process.chdir(PKG_ROOT);
25
33
 
26
34
  // ============================================================================
27
- // SETUP WIZARD
35
+ // HELPER FUNCTIONS
28
36
  // ============================================================================
29
37
 
38
+ /**
39
+ * Ensure the ~/.voicecc directory exists.
40
+ */
41
+ function ensureVoiceccDir() {
42
+ if (!existsSync(VOICECC_DIR)) {
43
+ mkdirSync(VOICECC_DIR, { recursive: true });
44
+ }
45
+ }
46
+
30
47
  /**
31
48
  * Prompt the user for a single line of input.
32
49
  *
@@ -64,6 +81,75 @@ function generatePassword() {
64
81
  return randomBytes(18).toString("base64url");
65
82
  }
66
83
 
84
+ /**
85
+ * Check if the daemon is currently running.
86
+ *
87
+ * @returns true if the PID file exists and the process is alive
88
+ */
89
+ function isRunning() {
90
+ if (!existsSync(PID_FILE)) return false;
91
+
92
+ const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
93
+ if (isNaN(pid)) return false;
94
+
95
+ try {
96
+ process.kill(pid, 0);
97
+ return true;
98
+ } catch {
99
+ // Process not found, clean up stale PID file
100
+ unlinkSync(PID_FILE);
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Read the status.json written by the server.
107
+ *
108
+ * @returns parsed status object or null if unavailable
109
+ */
110
+ function readStatus() {
111
+ try {
112
+ return JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Display server info banner.
120
+ */
121
+ function showInfo() {
122
+ const status = readStatus();
123
+
124
+ console.log("");
125
+ console.log("========================================");
126
+ console.log(" VOICECC RUNNING ");
127
+ console.log("========================================");
128
+ console.log("");
129
+
130
+ if (status) {
131
+ console.log(` Dashboard: http://localhost:${status.dashboardPort}`);
132
+ console.log(` Tunnel: ${status.tunnelUrl ?? "disabled"}`);
133
+ } else {
134
+ console.log(" Server is starting up...");
135
+ console.log(" Run 'voicecc' again in a few seconds to see details.");
136
+ }
137
+
138
+ console.log("");
139
+ console.log(" Logs: voicecc logs");
140
+ console.log(" Stop: voicecc stop");
141
+ console.log("");
142
+ console.log(" TIP: Run 'voicecc autostart' to start VoiceCC");
143
+ console.log(" automatically on reboot.");
144
+ console.log("");
145
+ console.log("========================================");
146
+ console.log("");
147
+ }
148
+
149
+ // ============================================================================
150
+ // SETUP WIZARD
151
+ // ============================================================================
152
+
67
153
  /**
68
154
  * Run the first-run setup wizard.
69
155
  * Prompts for ElevenLabs API key and dashboard password configuration.
@@ -195,10 +281,248 @@ function chownPkgRoot() {
195
281
  execSync(`chown -R ${VOICECC_USER}:${VOICECC_USER} ${PKG_ROOT}`, { stdio: "inherit" });
196
282
  }
197
283
 
284
+ // ============================================================================
285
+ // SUBCOMMANDS
286
+ // ============================================================================
287
+
288
+ /**
289
+ * Stop the running daemon.
290
+ */
291
+ function stopDaemon() {
292
+ if (!isRunning()) {
293
+ console.log("VoiceCC is not running.");
294
+ process.exit(0);
295
+ }
296
+
297
+ const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
298
+ console.log(`Stopping VoiceCC (PID ${pid})...`);
299
+
300
+ try {
301
+ process.kill(pid, "SIGTERM");
302
+ } catch {
303
+ // Already dead
304
+ }
305
+
306
+ // Clean up
307
+ try { unlinkSync(PID_FILE); } catch { /* ignore */ }
308
+ try { unlinkSync(STATUS_FILE); } catch { /* ignore */ }
309
+
310
+ console.log("VoiceCC stopped.");
311
+ }
312
+
313
+ /**
314
+ * Tail the log file.
315
+ */
316
+ function showLogs() {
317
+ if (!existsSync(LOG_FILE)) {
318
+ console.log("No log file found. Has VoiceCC been started?");
319
+ process.exit(1);
320
+ }
321
+
322
+ const child = spawn("tail", ["-f", LOG_FILE], { stdio: "inherit" });
323
+ process.on("SIGINT", () => child.kill("SIGINT"));
324
+ child.on("exit", (code) => process.exit(code ?? 0));
325
+ }
326
+
327
+ /**
328
+ * Set up auto-start on reboot using systemd (Linux) or launchd (macOS).
329
+ */
330
+ function setupAutostart() {
331
+ const os = platform();
332
+
333
+ if (os === "linux") {
334
+ setupSystemdAutostart();
335
+ } else if (os === "darwin") {
336
+ setupLaunchdAutostart();
337
+ } else {
338
+ console.log(`Autostart is not supported on ${os}.`);
339
+ process.exit(1);
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Install a systemd service for auto-start on Linux.
345
+ * Requires sudo for writing to /etc/systemd/system.
346
+ */
347
+ function setupSystemdAutostart() {
348
+ const user = execSync("whoami", { encoding: "utf-8" }).trim();
349
+
350
+ const serviceContent = `[Unit]
351
+ Description=VoiceCC Voice Server
352
+ After=network.target
353
+
354
+ [Service]
355
+ Type=simple
356
+ User=${user}
357
+ ExecStart=${TSX_BIN} server/index.ts
358
+ Restart=on-failure
359
+ RestartSec=5
360
+ Environment=HOME=${homedir()}
361
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin
362
+ WorkingDirectory=${PKG_ROOT}
363
+
364
+ [Install]
365
+ WantedBy=multi-user.target
366
+ `;
367
+
368
+ const tmpPath = join(VOICECC_DIR, "voicecc.service");
369
+ writeFileSync(tmpPath, serviceContent);
370
+
371
+ console.log("Installing systemd service (sudo required)...");
372
+ console.log("");
373
+
374
+ try {
375
+ execSync(`sudo cp ${tmpPath} /etc/systemd/system/voicecc.service`, { stdio: "inherit" });
376
+ execSync("sudo systemctl daemon-reload", { stdio: "inherit" });
377
+ execSync("sudo systemctl enable voicecc", { stdio: "inherit" });
378
+ console.log("");
379
+ console.log("Autostart enabled! VoiceCC will start on reboot.");
380
+ console.log("The systemd service manages the daemon separately.");
381
+ console.log("");
382
+ console.log(" sudo systemctl start voicecc Start now via systemd");
383
+ console.log(" sudo systemctl stop voicecc Stop via systemd");
384
+ console.log(" sudo systemctl status voicecc Check status");
385
+ console.log("");
386
+ } catch {
387
+ console.log("Failed to install systemd service. Check sudo permissions.");
388
+ process.exit(1);
389
+ } finally {
390
+ try { unlinkSync(tmpPath); } catch { /* ignore */ }
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Install a launchd agent for auto-start on macOS.
396
+ * No sudo required (user-level agent).
397
+ */
398
+ function setupLaunchdAutostart() {
399
+ const plistName = "com.voicecc.server";
400
+ const plistDir = join(homedir(), "Library", "LaunchAgents");
401
+ const plistPath = join(plistDir, `${plistName}.plist`);
402
+
403
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
404
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
405
+ <plist version="1.0">
406
+ <dict>
407
+ <key>Label</key>
408
+ <string>${plistName}</string>
409
+ <key>ProgramArguments</key>
410
+ <array>
411
+ <string>${TSX_BIN}</string>
412
+ <string>server/index.ts</string>
413
+ </array>
414
+ <key>RunAtLoad</key>
415
+ <true/>
416
+ <key>KeepAlive</key>
417
+ <true/>
418
+ <key>WorkingDirectory</key>
419
+ <string>${PKG_ROOT}</string>
420
+ <key>EnvironmentVariables</key>
421
+ <dict>
422
+ <key>HOME</key>
423
+ <string>${homedir()}</string>
424
+ <key>PATH</key>
425
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
426
+ </dict>
427
+ <key>StandardOutPath</key>
428
+ <string>${LOG_FILE}</string>
429
+ <key>StandardErrorPath</key>
430
+ <string>${LOG_FILE}</string>
431
+ </dict>
432
+ </plist>
433
+ `;
434
+
435
+ if (!existsSync(plistDir)) {
436
+ mkdirSync(plistDir, { recursive: true });
437
+ }
438
+
439
+ try {
440
+ // Unload existing if present
441
+ if (existsSync(plistPath)) {
442
+ try {
443
+ execSync(`launchctl unload ${plistPath}`, { stdio: "ignore" });
444
+ } catch { /* ignore */ }
445
+ }
446
+
447
+ writeFileSync(plistPath, plistContent);
448
+ execSync(`launchctl load ${plistPath}`, { stdio: "inherit" });
449
+
450
+ console.log("");
451
+ console.log("Autostart enabled! VoiceCC will start on login.");
452
+ console.log("");
453
+ console.log(` Plist: ${plistPath}`);
454
+ console.log("");
455
+ console.log(" To disable autostart:");
456
+ console.log(` launchctl unload ${plistPath}`);
457
+ console.log("");
458
+ } catch (err) {
459
+ console.log(`Failed to install launchd agent: ${err.message}`);
460
+ process.exit(1);
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Start the server as a detached background daemon.
466
+ */
467
+ function startDaemon() {
468
+ ensureVoiceccDir();
469
+
470
+ // Clean up stale status file
471
+ try { unlinkSync(STATUS_FILE); } catch { /* ignore */ }
472
+
473
+ const logFd = openSync(LOG_FILE, "a");
474
+
475
+ const isRoot = process.getuid && process.getuid() === 0;
476
+
477
+ let child;
478
+ if (isRoot) {
479
+ ensureNonRootUser();
480
+ chownPkgRoot();
481
+ console.log(`Dropping root privileges, running as '${VOICECC_USER}'...`);
482
+ child = spawn("su", ["-", VOICECC_USER, "-c", `cd ${PKG_ROOT} && ${TSX_BIN} server/index.ts`], {
483
+ cwd: PKG_ROOT,
484
+ detached: true,
485
+ stdio: ["ignore", logFd, logFd],
486
+ });
487
+ } else {
488
+ child = spawn(TSX_BIN, ["server/index.ts"], {
489
+ cwd: PKG_ROOT,
490
+ detached: true,
491
+ stdio: ["ignore", logFd, logFd],
492
+ });
493
+ }
494
+
495
+ // Write PID file
496
+ writeFileSync(PID_FILE, String(child.pid));
497
+
498
+ // Detach parent from child
499
+ child.unref();
500
+ closeSync(logFd);
501
+ }
502
+
198
503
  // ============================================================================
199
504
  // MAIN ENTRYPOINT
200
505
  // ============================================================================
201
506
 
507
+ const subcommand = process.argv[2];
508
+
509
+ // Handle subcommands
510
+ if (subcommand === "stop") {
511
+ stopDaemon();
512
+ process.exit(0);
513
+ }
514
+
515
+ if (subcommand === "logs") {
516
+ showLogs();
517
+ // showLogs doesn't return (tail -f)
518
+ }
519
+
520
+ if (subcommand === "autostart") {
521
+ ensureVoiceccDir();
522
+ setupAutostart();
523
+ process.exit(0);
524
+ }
525
+
202
526
  // Copy CLAUDE.md template if available
203
527
  const claudeMdSrc = join("init", "CLAUDE.md");
204
528
  if (existsSync(claudeMdSrc)) {
@@ -210,29 +534,15 @@ if (!existsSync(ENV_PATH)) {
210
534
  await runSetupWizard();
211
535
  }
212
536
 
213
- // If running as root, re-exec as a non-root user
214
- const isRoot = process.getuid && process.getuid() === 0;
215
- if (isRoot) {
216
- ensureNonRootUser();
217
- chownPkgRoot();
218
-
219
- console.log(`Dropping root privileges, running as '${VOICECC_USER}'...`);
220
- const child = spawn("su", ["-", VOICECC_USER, "-c", `cd ${PKG_ROOT} && ${TSX_BIN} server/index.ts`], {
221
- cwd: PKG_ROOT,
222
- stdio: "inherit",
223
- });
537
+ // If already running, show info and exit
538
+ if (isRunning()) {
539
+ showInfo();
540
+ process.exit(0);
541
+ }
224
542
 
225
- process.on("SIGINT", () => child.kill("SIGINT"));
226
- process.on("SIGTERM", () => child.kill("SIGTERM"));
227
- child.on("exit", (code) => process.exit(code ?? 1));
228
- } else {
229
- // Start the dashboard directly
230
- const child = spawn(TSX_BIN, ["server/index.ts"], {
231
- cwd: PKG_ROOT,
232
- stdio: "inherit",
233
- });
543
+ // Start the daemon
544
+ startDaemon();
234
545
 
235
- process.on("SIGINT", () => child.kill("SIGINT"));
236
- process.on("SIGTERM", () => child.kill("SIGTERM"));
237
- child.on("exit", (code) => process.exit(code ?? 1));
238
- }
546
+ // Wait briefly for server to write status.json, then show info
547
+ await new Promise((resolve) => setTimeout(resolve, 3000));
548
+ showInfo();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voicecc",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
4
4
  "description": "Voice mode plugin for Claude Code -- hands-free interaction via ElevenLabs STT/TTS and VAD",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -10,6 +10,10 @@
10
10
 
11
11
  import "dotenv/config";
12
12
 
13
+ import { writeFileSync, unlinkSync, mkdirSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { homedir } from "node:os";
16
+
13
17
  import { startDashboard } from "../dashboard/server.js";
14
18
  import { readEnv } from "./services/env.js";
15
19
  import { startTunnel, isTunnelRunning, getTunnelUrl } from "./services/tunnel.js";
@@ -18,6 +22,39 @@ import { startBrowserCallServer } from "./services/browser-call-manager.js";
18
22
  import { startHeartbeat } from "./services/heartbeat.js";
19
23
  import { startVoiceServer } from "./voice/voice-server.js";
20
24
 
25
+ const STATUS_FILE = join(homedir(), ".voicecc", "status.json");
26
+
27
+ // ============================================================================
28
+ // HELPER FUNCTIONS
29
+ // ============================================================================
30
+
31
+ /**
32
+ * Write server status to ~/.voicecc/status.json so the CLI can display info.
33
+ *
34
+ * @param dashboardPort - the port the dashboard is running on
35
+ * @param tunnelUrl - the tunnel URL, or null if disabled
36
+ */
37
+ function writeStatusFile(dashboardPort: number, tunnelUrl: string | null): void {
38
+ const status = {
39
+ dashboardPort,
40
+ tunnelUrl,
41
+ startedAt: new Date().toISOString(),
42
+ };
43
+ try {
44
+ mkdirSync(join(homedir(), ".voicecc"), { recursive: true });
45
+ writeFileSync(STATUS_FILE, JSON.stringify(status, null, 2));
46
+ } catch {
47
+ console.error("Failed to write status file");
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Remove the status file on shutdown.
53
+ */
54
+ function cleanupStatusFile(): void {
55
+ try { unlinkSync(STATUS_FILE); } catch { /* ignore */ }
56
+ }
57
+
21
58
  // ============================================================================
22
59
  // MAIN ENTRYPOINT
23
60
  // ============================================================================
@@ -67,8 +104,15 @@ async function main(): Promise<void> {
67
104
  }
68
105
  }
69
106
 
70
- // Print startup banner
107
+ // Write status file so the CLI can display server info
71
108
  const tunnelUrl = getTunnelUrl();
109
+ writeStatusFile(dashboardPort, tunnelUrl);
110
+
111
+ // Clean up status file on shutdown
112
+ process.on("SIGTERM", () => { cleanupStatusFile(); process.exit(0); });
113
+ process.on("SIGINT", () => { cleanupStatusFile(); process.exit(0); });
114
+
115
+ // Print startup banner
72
116
  console.log("");
73
117
  console.log("========================================");
74
118
  console.log(" VOICECC RUNNING ");
@@ -77,8 +121,6 @@ async function main(): Promise<void> {
77
121
  console.log(` Dashboard: http://localhost:${dashboardPort}`);
78
122
  console.log(` Tunnel: ${tunnelUrl ?? "disabled"}`);
79
123
  console.log("");
80
- console.log(" Press Ctrl+C to stop.");
81
- console.log("");
82
124
  }
83
125
 
84
126
  // ============================================================================