Skip to content

Commit 9359b05

Browse files
committed
refactor: enhance error handling and improve command usage messages
- Updated error handling in audio context and microphone initialization to provide more specific warnings based on the encountered issues. - Modified command usage messages across various scripts to standardize the format and improve clarity, ensuring users understand the correct syntax for commands. - Added support for new resource types in interactive prompts and improved the handling of locally modified files during resource pulls. - Introduced a new grouping mechanism in the searchable checkbox for better organization of choices in interactive prompts.
1 parent 0bcb530 commit 9359b05

10 files changed

Lines changed: 299 additions & 98 deletions

File tree

src/call.ts

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import { join, dirname, resolve } from "path";
33
import { fileURLToPath } from "url";
44
import { execSync } from "child_process";
55
import * as readline from "readline";
6+
import { createRequire } from "module";
67
import type { Environment, StateFile } from "./types.ts";
78

9+
const require = createRequire(import.meta.url);
10+
811
// ─────────────────────────────────────────────────────────────────────────────
912
// Configuration
1013
// ─────────────────────────────────────────────────────────────────────────────
@@ -205,7 +208,7 @@ async function checkMicrophonePermission(): Promise<boolean> {
205208
" If prompted, please grant microphone permission in System Preferences.",
206209
);
207210
console.log(
208-
" System Preferences > Security & Privacy > Privacy > Microphone\n",
211+
" System Settings > Privacy & Security > Microphone\n",
209212
);
210213

211214
// Ask user to continue anyway
@@ -269,7 +272,7 @@ function loadState(env: Environment): StateFile {
269272
if (!existsSync(stateFilePath)) {
270273
console.error(`❌ State file not found: .vapi-state.${env}.json`);
271274
console.error(
272-
" Run 'npm run apply:" + env + "' first to create resources",
275+
" Run 'npm run apply -- " + env + "' first to create resources",
273276
);
274277
process.exit(1);
275278
}
@@ -468,11 +471,21 @@ async function connectWebSocket(
468471
}
469472
};
470473

471-
ws.onmessage = (event) => {
472-
if (event.data instanceof Buffer || event.data instanceof ArrayBuffer) {
473-
// Binary audio data from assistant
474+
ws.onmessage = async (event) => {
475+
const data = event.data;
476+
// Binary audio data from assistant
477+
if (data instanceof Buffer || data instanceof ArrayBuffer) {
478+
if (audioContext) {
479+
audioContext.playAudio(data);
480+
}
481+
} else if (typeof Blob !== "undefined" && data instanceof Blob) {
482+
if (audioContext) {
483+
const arrayBuffer = await data.arrayBuffer();
484+
audioContext.playAudio(arrayBuffer);
485+
}
486+
} else if (ArrayBuffer.isView(data)) {
474487
if (audioContext) {
475-
audioContext.playAudio(event.data);
488+
audioContext.playAudio(data.buffer as ArrayBuffer);
476489
}
477490
} else {
478491
// Control message (JSON)
@@ -539,7 +552,20 @@ function handleControlMessage(
539552
}
540553
case "call-ended": {
541554
const cm = message as CallEndedMessage;
542-
console.log(`\n📞 Call ended: ${cm.reason || "unknown reason"}`);
555+
const reasonLabels: Record<string, string> = {
556+
"silence-timed-out": "Silence timeout (no speech detected)",
557+
"assistant-ended-call": "Assistant ended the call",
558+
"customer-ended-call": "Customer ended the call",
559+
"max-duration-reached": "Maximum call duration reached",
560+
"assistant-error": "Assistant error",
561+
"pipeline-error": "Pipeline error",
562+
"voicemail-reached": "Voicemail detected",
563+
"customer-did-not-answer": "No answer",
564+
"assistant-request-returned-error": "Assistant request error",
565+
"assistant-not-found": "Assistant not found",
566+
};
567+
const label = cm.reason ? (reasonLabels[cm.reason] ?? cm.reason) : "unknown reason";
568+
console.log(`\n📞 Call ended: ${label}`);
543569
break;
544570
}
545571
default:
@@ -584,23 +610,27 @@ function createAudioContext(): {
584610
playAudio: (data: Buffer | ArrayBuffer) => void;
585611
close: () => void;
586612
} {
587-
// Lazy load speaker module
588613
let Speaker: SpeakerConstructor | null = null;
589614
let speakerInstance: SpeakerInstance | null = null;
590615

591616
try {
592-
// Dynamic import for optional dependency
593617
Speaker = require("speaker") as SpeakerConstructor;
594618
speakerInstance = new Speaker!({
595619
channels: 1,
596620
bitDepth: 16,
597621
sampleRate: 16000,
598622
});
599-
} catch {
600-
console.warn(
601-
"⚠️ 'speaker' module not installed. Audio playback disabled.",
602-
);
603-
console.warn(" Install with: npm install speaker");
623+
} catch (error) {
624+
const msg = error instanceof Error ? error.message : String(error);
625+
if (msg.includes("Cannot find module")) {
626+
console.warn("⚠️ 'speaker' module not installed. Audio playback disabled.");
627+
console.warn(" Install with: npm install speaker");
628+
} else if (msg.includes("Could not locate the bindings file") || msg.includes("NODE_MODULE_VERSION")) {
629+
console.warn("⚠️ 'speaker' native bindings not built for this Node version.");
630+
console.warn(" Rebuild with: npm rebuild speaker");
631+
} else {
632+
console.warn(`⚠️ Could not initialize speaker: ${msg}`);
633+
}
604634
}
605635

606636
return {
@@ -647,9 +677,16 @@ function createMicrophoneStream(onData: (data: Buffer) => void): {
647677

648678
micInstance!.start();
649679
} catch (error) {
650-
console.warn("⚠️ 'mic' module not installed or microphone unavailable.");
651-
console.warn(" Install with: npm install mic");
652-
console.warn(" Error:", error);
680+
const msg = error instanceof Error ? error.message : String(error);
681+
if (msg.includes("Cannot find module")) {
682+
console.warn("⚠️ 'mic' module not installed. Microphone input disabled.");
683+
console.warn(" Install with: npm install mic");
684+
} else if (msg.includes("sox") || msg.includes("rec")) {
685+
console.warn("⚠️ sox/rec not found. Required for microphone input.");
686+
console.warn(" Install with: brew install sox (macOS) or apt install sox (Linux)");
687+
} else {
688+
console.warn(`⚠️ Could not initialize microphone: ${msg}`);
689+
}
653690
}
654691

655692
return {

src/cleanup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ async function main(): Promise<void> {
203203
);
204204
console.log("🔒 DRY-RUN MODE - No resources were deleted");
205205
console.log(" To actually delete, run:");
206-
console.log(` npm run cleanup:${VAPI_ENV} -- --force`);
206+
console.log(` npm run cleanup -- ${VAPI_ENV} --force`);
207207
console.log(
208208
"═══════════════════════════════════════════════════════════════\n",
209209
);

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ function parseEnvironment(): Environment {
3939

4040
if (!envArg) {
4141
console.error("❌ Environment / org name argument is required");
42-
console.error(" Usage: npm run push <org> | npm run push:dev");
42+
console.error(" Usage: npm run push -- <org>");
4343
console.error(" Flags: --force (enable deletions)");
4444
console.error(
4545
" --type <type> (apply only specific resource type, repeatable)",

src/delete.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ export async function deleteOrphanedResources(
220220
}
221221
console.log(" ℹ️ These resources exist in Vapi but not in your local files.");
222222
console.log(" ℹ️ To delete them, run with --force flag:");
223-
console.log(" npm run apply:dev:force\n");
223+
console.log(" npm run push -- <org> --force\n");
224224
return;
225225
}
226226

src/eval.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function printUsage(): void {
3838
console.error(" tsx src/eval.ts <env> -a <assistant-name> [options]");
3939
console.error("");
4040
console.error("Runs Vapi Evals (mock conversation tests) against a transient or stored assistant/squad.");
41-
console.error("Evals must be pushed first (npm run push:dev evals). Assistants/squads can be transient.");
41+
console.error("Evals must be pushed first (npm run push -- <org> evals). Assistants/squads can be transient.");
4242
console.error("");
4343
console.error("Options:");
4444
console.error(" -s <name> Target squad (by resource filename, loaded as transient)");
@@ -135,7 +135,7 @@ function loadState(env: Environment): StateFile {
135135
const stateFile = join(BASE_DIR, `.vapi-state.${env}.json`);
136136
if (!existsSync(stateFile)) {
137137
console.error(`❌ State file not found: .vapi-state.${env}.json`);
138-
console.error(" Run 'npm run push:dev evals' first to create eval resources");
138+
console.error(" Run 'npm run push -- <org> evals' first to create eval resources");
139139
process.exit(1);
140140
}
141141
const content = readFileSync(stateFile, "utf-8");
@@ -494,7 +494,7 @@ async function main(): Promise<void> {
494494
const evals = loadEvals(state, config.evalFilter);
495495
if (evals.length === 0) {
496496
console.error("❌ No evals found in state" + (config.evalFilter ? ` matching "${config.evalFilter}"` : ""));
497-
console.error(" Push evals first: npm run push:dev evals");
497+
console.error(" Push evals first: npm run push -- <org> evals");
498498
console.error(" Eval files go in: resources/evals/");
499499
process.exit(1);
500500
}

src/interactive.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ const RESOURCE_TYPES: ResourceTypeDef[] = [
6161
endpoint: "/eval/simulation/suite",
6262
folder: "simulations/suites",
6363
},
64+
{
65+
key: "evals",
66+
label: "Evals",
67+
endpoint: "/eval",
68+
folder: "evals",
69+
},
6470
];
6571

6672
// ─────────────────────────────────────────────────────────────────────────────
@@ -485,6 +491,7 @@ export async function runInteractivePull(): Promise<void> {
485491

486492
console.log(c.dim(" Fetching remote resources...\n"));
487493

494+
let fetchFailed = false;
488495
snapshots = await Promise.all(
489496
RESOURCE_TYPES.map(
490497
async (type): Promise<ResourceSnapshot> => {
@@ -495,13 +502,24 @@ export async function runInteractivePull(): Promise<void> {
495502
label: type.label,
496503
resources: normaliseList(data),
497504
};
498-
} catch {
505+
} catch (error) {
506+
const msg = error instanceof Error ? error.message : String(error);
507+
if (msg.includes("401") || msg.includes("403") || msg.includes("authentication") || msg.includes("unauthorized")) {
508+
fetchFailed = true;
509+
}
510+
console.log(c.red(` ✗ Failed to fetch ${type.label}: ${msg}`));
499511
return { key: type.key, label: type.label, resources: [] };
500512
}
501513
},
502514
),
503515
);
504516

517+
if (fetchFailed) {
518+
console.log(c.red("\n ⚠ API authentication failed. Check your VAPI_TOKEN in .env." + slug));
519+
console.log(c.red(" Run \"npm run setup\" to reconfigure.\n"));
520+
return;
521+
}
522+
505523
nonEmpty = snapshots.filter((s) => s.resources.length > 0);
506524
totalCount = nonEmpty.reduce(
507525
(n, s) => n + s.resources.length,
@@ -981,24 +999,17 @@ export async function runInteractiveCleanup(): Promise<void> {
981999
),
9821000
);
9831001

984-
const dryFirst = await confirm({
985-
message: "Run dry-run first to preview what would be deleted?",
986-
default: true,
987-
});
988-
989-
if (dryFirst) {
990-
console.log(c.dim("\n Running dry-run...\n"));
991-
spawnScript(["src/cleanup.ts", slug]);
1002+
console.log(c.dim("\n Running dry-run preview...\n"));
1003+
spawnScript(["src/cleanup.ts", slug]);
9921004

993-
const proceed = await confirm({
994-
message: "Proceed with actual deletion?",
995-
default: false,
996-
});
1005+
const proceed = await confirm({
1006+
message: "Proceed with actual deletion?",
1007+
default: false,
1008+
});
9971009

998-
if (!proceed) {
999-
console.log(c.dim("\n Cancelled.\n"));
1000-
return;
1001-
}
1010+
if (!proceed) {
1011+
console.log(c.dim("\n Cancelled.\n"));
1012+
return;
10021013
}
10031014

10041015
console.log(c.dim("\n Running cleanup with --force...\n"));

src/pull.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execSync } from "child_process";
2-
import { existsSync, readdirSync } from "fs";
2+
import { existsSync, readdirSync, statSync } from "fs";
33
import { mkdir, writeFile } from "fs/promises";
44
import { join, dirname, relative, resolve } from "path";
55
import { fileURLToPath } from "url";
@@ -686,8 +686,30 @@ export async function pullResourceType(
686686
}
687687
}
688688

689+
// Skip locally edited files even without git (mtime-based detection)
690+
// If the resource file is newer than the state file, it was locally modified
691+
if (!bootstrap && !force && !isNew && !changedFiles) {
692+
const folderPath = FOLDER_MAP[resourceType];
693+
const dir = join(RESOURCES_DIR, folderPath);
694+
const localFile =
695+
[join(dir, `${resourceId}.md`), join(dir, `${resourceId}.yml`), join(dir, `${resourceId}.yaml`)]
696+
.find((p) => existsSync(p));
697+
if (localFile) {
698+
const stateFilePath = join(BASE_DIR, `.vapi-state.${VAPI_ENV}.json`);
699+
if (existsSync(stateFilePath)) {
700+
const localMtime = statSync(localFile).mtimeMs;
701+
const stateMtime = statSync(stateFilePath).mtimeMs;
702+
if (localMtime > stateMtime) {
703+
console.log(` ⏭️ ${resourceId} (locally modified, skipping)`);
704+
newStateSection[resourceId] = resource.id;
705+
skipped++;
706+
continue;
707+
}
708+
}
709+
}
710+
}
711+
689712
// Skip resources whose local file was deleted (works without git)
690-
// A resource that was previously tracked (in state) but has no local file = intentional deletion
691713
if (!bootstrap && !force && !isNew) {
692714
const folderPath = FOLDER_MAP[resourceType];
693715
const dir = join(RESOURCES_DIR, folderPath);
@@ -762,7 +784,7 @@ export async function runPull(options: PullOptions = {}): Promise<PullResult> {
762784
if (resourceIds?.length) {
763785
if (!typeFilter?.length || typeFilter.length !== 1) {
764786
throw new Error(
765-
"Single-resource pull requires exactly one resource type. Example: npm run pull:dev -- squads --id <uuid>",
787+
"Single-resource pull requires exactly one resource type. Example: npm run pull -- <org> --type squads --id <uuid>",
766788
);
767789
}
768790
}
@@ -929,7 +951,7 @@ export async function runPull(options: PullOptions = {}): Promise<PullResult> {
929951

930952
if (totalSkipped > 0) {
931953
console.log(`\n ℹ️ ${totalSkipped} file(s) preserved (locally changed)`);
932-
console.log(" Run with --force to overwrite: npm run pull:dev:force");
954+
console.log(" Run with --force to overwrite: npm run pull -- <org> --force");
933955
}
934956

935957
return { state, stats, force, bootstrap };

src/push.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
VAPI_BASE_URL,
77
FORCE_DELETE,
88
APPLY_FILTER,
9+
BASE_DIR,
910
removeExcludedKeys,
1011
} from "./config.ts";
1112
import { loadState, saveState } from "./state.ts";
@@ -628,13 +629,14 @@ function filterResourcesByPaths<T>(
628629
): ResourceFile<T>[] {
629630
if (!APPLY_FILTER.filePaths?.length) return resources;
630631

631-
// Get all resourceIds that match the file paths for this type
632632
const matchingIds = new Set<string>();
633633

634634
for (const filePath of APPLY_FILTER.filePaths) {
635-
// Try to match the file path to a resourceId
635+
const resolvedInput = resolve(BASE_DIR, filePath);
636+
636637
for (const resource of resources) {
637638
if (
639+
resource.filePath === resolvedInput ||
638640
resource.filePath.endsWith(filePath) ||
639641
filePath.endsWith(resource.resourceId + ".yml") ||
640642
filePath.endsWith(resource.resourceId + ".yaml") ||

0 commit comments

Comments
 (0)