gsd-pi 2.45.0-dev.6b9da3e → 2.45.0-dev.e0ee972

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.
Files changed (104) hide show
  1. package/dist/help-text.js +1 -1
  2. package/dist/loader.js +34 -0
  3. package/dist/resources/extensions/gsd/auto/phases.js +16 -10
  4. package/dist/resources/extensions/gsd/auto/run-unit.js +6 -3
  5. package/dist/resources/extensions/gsd/auto-worktree.js +5 -4
  6. package/dist/resources/extensions/gsd/auto.js +4 -5
  7. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +13 -12
  8. package/dist/resources/extensions/gsd/db-writer.js +9 -9
  9. package/dist/resources/extensions/gsd/doctor-checks.js +1 -1
  10. package/dist/resources/extensions/gsd/doctor.js +2 -2
  11. package/dist/resources/extensions/gsd/gsd-db.js +5 -1
  12. package/dist/resources/extensions/gsd/preferences-types.js +2 -2
  13. package/dist/resources/extensions/gsd/preferences.js +8 -4
  14. package/dist/resources/extensions/gsd/workflow-logger.js +138 -0
  15. package/dist/resources/extensions/gsd/worktree-manager.js +4 -3
  16. package/dist/resources/extensions/gsd/worktree-resolver.js +37 -0
  17. package/dist/resources/extensions/voice/index.js +11 -16
  18. package/dist/resources/extensions/voice/linux-ready.js +67 -0
  19. package/dist/web/standalone/.next/BUILD_ID +1 -1
  20. package/dist/web/standalone/.next/app-path-routes-manifest.json +19 -19
  21. package/dist/web/standalone/.next/build-manifest.json +2 -2
  22. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  23. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  24. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.html +1 -1
  40. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app-paths-manifest.json +19 -19
  47. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  48. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  49. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  50. package/package.json +1 -1
  51. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.d.ts.map +1 -1
  52. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.js +2 -0
  53. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.js.map +1 -1
  54. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -1
  55. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  56. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  57. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.d.ts +4 -0
  58. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.d.ts.map +1 -1
  59. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.js +10 -5
  60. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.js.map +1 -1
  61. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.test.d.ts +2 -0
  62. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.test.d.ts.map +1 -0
  63. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.test.js +185 -0
  64. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.test.js.map +1 -0
  65. package/packages/pi-coding-agent/dist/core/model-registry-auth-mode.test.js +239 -10
  66. package/packages/pi-coding-agent/dist/core/model-registry-auth-mode.test.js.map +1 -1
  67. package/packages/pi-coding-agent/dist/core/model-registry.d.ts +2 -1
  68. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  69. package/packages/pi-coding-agent/dist/core/model-registry.js +20 -2
  70. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  71. package/packages/pi-coding-agent/dist/core/package-commands.test.js +206 -195
  72. package/packages/pi-coding-agent/dist/core/package-commands.test.js.map +1 -1
  73. package/packages/pi-coding-agent/src/core/compaction-orchestrator.ts +2 -0
  74. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -1
  75. package/packages/pi-coding-agent/src/core/lifecycle-hooks.test.ts +227 -0
  76. package/packages/pi-coding-agent/src/core/lifecycle-hooks.ts +11 -5
  77. package/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts +297 -11
  78. package/packages/pi-coding-agent/src/core/model-registry.ts +30 -3
  79. package/packages/pi-coding-agent/src/core/package-commands.test.ts +227 -205
  80. package/src/resources/extensions/gsd/auto/phases.ts +16 -12
  81. package/src/resources/extensions/gsd/auto/run-unit.ts +6 -3
  82. package/src/resources/extensions/gsd/auto-worktree.ts +8 -5
  83. package/src/resources/extensions/gsd/auto.ts +3 -3
  84. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +13 -12
  85. package/src/resources/extensions/gsd/db-writer.ts +9 -17
  86. package/src/resources/extensions/gsd/doctor-checks.ts +1 -1
  87. package/src/resources/extensions/gsd/doctor.ts +2 -2
  88. package/src/resources/extensions/gsd/gsd-db.ts +5 -1
  89. package/src/resources/extensions/gsd/journal.ts +6 -1
  90. package/src/resources/extensions/gsd/preferences-types.ts +2 -2
  91. package/src/resources/extensions/gsd/preferences.ts +7 -3
  92. package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +1 -1
  93. package/src/resources/extensions/gsd/tests/none-mode-gates.test.ts +42 -3
  94. package/src/resources/extensions/gsd/tests/preferences.test.ts +7 -9
  95. package/src/resources/extensions/gsd/tests/workflow-logger.test.ts +275 -0
  96. package/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts +220 -0
  97. package/src/resources/extensions/gsd/workflow-logger.ts +193 -0
  98. package/src/resources/extensions/gsd/worktree-manager.ts +4 -9
  99. package/src/resources/extensions/gsd/worktree-resolver.ts +37 -0
  100. package/src/resources/extensions/voice/index.ts +11 -21
  101. package/src/resources/extensions/voice/linux-ready.ts +87 -0
  102. package/src/resources/extensions/voice/tests/linux-ready.test.ts +124 -0
  103. /package/dist/web/standalone/.next/static/{rzO54ZboyINyEt7cVM_uS → dFMji9G1LZ-Tv36el9pRT}/_buildManifest.js +0 -0
  104. /package/dist/web/standalone/.next/static/{rzO54ZboyINyEt7cVM_uS → dFMji9G1LZ-Tv36el9pRT}/_ssgManifest.js +0 -0
@@ -19,6 +19,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:
19
19
  import { execFileSync } from "node:child_process";
20
20
  import { join, resolve, sep } from "node:path";
21
21
  import { GSDError, GSD_PARSE_ERROR, GSD_STALE_STATE, GSD_LOCK_HELD, GSD_GIT_ERROR, GSD_MERGE_CONFLICT } from "./errors.js";
22
+ import { logWarning } from "./workflow-logger.js";
22
23
  import {
23
24
  nativeBranchDelete,
24
25
  nativeBranchExists,
@@ -136,9 +137,7 @@ export function createWorktree(basePath: string, name: string, opts: { branch?:
136
137
  // worktree can be created in its place.
137
138
  const gitFilePath = join(wtPath, ".git");
138
139
  if (!existsSync(gitFilePath)) {
139
- console.error(
140
- `[GSD] Removing stale worktree directory (no .git file): ${wtPath}`,
141
- );
140
+ logWarning("reconcile", `Removing stale worktree directory (no .git file): ${wtPath}`, { worktree: name });
142
141
  rmSync(wtPath, { recursive: true, force: true });
143
142
  } else {
144
143
  throw new GSDError(GSD_STALE_STATE, `Worktree "${name}" already exists at ${wtPath}`);
@@ -345,14 +344,10 @@ export function removeWorktree(
345
344
  "git", ["stash", "push", "-m", "gsd: auto-stash submodule changes before worktree teardown"],
346
345
  { cwd: resolvedWtPath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
347
346
  );
348
- process.stderr.write(
349
- `[GSD] WARNING: Stashed uncommitted submodule changes in ${resolvedWtPath} before worktree teardown.\n`,
350
- );
347
+ logWarning("reconcile", `Stashed uncommitted submodule changes before worktree teardown`, { worktree: name, path: resolvedWtPath });
351
348
  } catch {
352
349
  // Stash failed — warn the user that submodule changes may be lost
353
- process.stderr.write(
354
- `[GSD] WARNING: Submodule changes detected in ${resolvedWtPath} — stash failed, changes may be lost during force removal.\n`,
355
- );
350
+ logWarning("reconcile", `Submodule changes detected — stash failed, changes may be lost during force removal`, { worktree: name, path: resolvedWtPath });
356
351
  }
357
352
  }
358
353
  } catch {
@@ -14,10 +14,12 @@
14
14
  */
15
15
 
16
16
  import { existsSync, unlinkSync } from "node:fs";
17
+ import { randomUUID } from "node:crypto";
17
18
  import { join } from "node:path";
18
19
  import type { AutoSession } from "./auto/session.js";
19
20
  import { debugLog } from "./debug-logger.js";
20
21
  import { MergeConflictError } from "./git-service.js";
22
+ import { emitJournalEvent } from "./journal.js";
21
23
 
22
24
  // ─── Dependency Interface ──────────────────────────────────────────────────
23
25
 
@@ -155,6 +157,13 @@ export class WorktreeResolver {
155
157
  skipped: true,
156
158
  reason: "isolation-disabled",
157
159
  });
160
+ emitJournalEvent(this.s.originalBasePath || this.s.basePath, {
161
+ ts: new Date().toISOString(),
162
+ flowId: randomUUID(),
163
+ seq: 0,
164
+ eventType: "worktree-skip",
165
+ data: { milestoneId, reason: "isolation-disabled" },
166
+ });
158
167
  return;
159
168
  }
160
169
 
@@ -184,6 +193,13 @@ export class WorktreeResolver {
184
193
  result: "success",
185
194
  wtPath,
186
195
  });
196
+ emitJournalEvent(this.s.originalBasePath || this.s.basePath, {
197
+ ts: new Date().toISOString(),
198
+ flowId: randomUUID(),
199
+ seq: 0,
200
+ eventType: "worktree-enter",
201
+ data: { milestoneId, wtPath, created: !existingPath },
202
+ });
187
203
  ctx.notify(`Entered worktree for ${milestoneId} at ${wtPath}`, "info");
188
204
  } catch (err) {
189
205
  const msg = err instanceof Error ? err.message : String(err);
@@ -193,6 +209,13 @@ export class WorktreeResolver {
193
209
  result: "error",
194
210
  error: msg,
195
211
  });
212
+ emitJournalEvent(this.s.originalBasePath || this.s.basePath, {
213
+ ts: new Date().toISOString(),
214
+ flowId: randomUUID(),
215
+ seq: 0,
216
+ eventType: "worktree-create-failed",
217
+ data: { milestoneId, error: msg, fallback: "project-root" },
218
+ });
196
219
  ctx.notify(
197
220
  `Auto-worktree creation for ${milestoneId} failed: ${msg}. Continuing in project root.`,
198
221
  "warning",
@@ -288,6 +311,13 @@ export class WorktreeResolver {
288
311
  mode,
289
312
  basePath: this.s.basePath,
290
313
  });
314
+ emitJournalEvent(this.s.originalBasePath || this.s.basePath, {
315
+ ts: new Date().toISOString(),
316
+ flowId: randomUUID(),
317
+ seq: 0,
318
+ eventType: "worktree-merge-start",
319
+ data: { milestoneId, mode },
320
+ });
291
321
 
292
322
  if (mode === "none") {
293
323
  debugLog("WorktreeResolver", {
@@ -408,6 +438,13 @@ export class WorktreeResolver {
408
438
  error: msg,
409
439
  fallback: "chdir-to-project-root",
410
440
  });
441
+ emitJournalEvent(this.s.originalBasePath || this.s.basePath, {
442
+ ts: new Date().toISOString(),
443
+ flowId: randomUUID(),
444
+ seq: 0,
445
+ eventType: "worktree-merge-failed",
446
+ data: { milestoneId, error: msg },
447
+ });
411
448
  // Surface a clear, actionable error. The worktree and milestone branch are
412
449
  // intentionally preserved — nothing has been deleted. The user can retry
413
450
  // /gsd dispatch complete-milestone or merge manually once the underlying issue is fixed
@@ -4,9 +4,9 @@ import type { AssistantMessage } from "@gsd/pi-ai";
4
4
  import { isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@gsd/pi-tui";
5
5
  import { spawn, execFileSync, type ChildProcess } from "node:child_process";
6
6
  import * as fs from "node:fs";
7
- import * as os from "node:os";
8
7
  import * as path from "node:path";
9
8
  import * as readline from "node:readline";
9
+ import { linuxPython, diagnoseSounddeviceError, ensureVoiceVenv, VOICE_VENV_PYTHON } from "./linux-ready.js";
10
10
 
11
11
  const __extensionDir = import.meta.dirname!;
12
12
  const SWIFT_SRC = path.join(__extensionDir, "speech-recognizer.swift");
@@ -15,19 +15,6 @@ const PYTHON_SCRIPT = path.join(__extensionDir, "speech-recognizer.py");
15
15
 
16
16
  const IS_DARWIN = process.platform === "darwin";
17
17
  const IS_LINUX = process.platform === "linux";
18
- const VOICE_VENV_PYTHON = path.join(
19
- process.env.HOME || process.env.USERPROFILE || os.homedir(),
20
- ".gsd",
21
- "voice-venv",
22
- "bin",
23
- "python3",
24
- );
25
-
26
- /** Return the python3 binary path — prefer venv if it exists, else system. */
27
- function linuxPython(): string {
28
- if (fs.existsSync(VOICE_VENV_PYTHON)) return VOICE_VENV_PYTHON;
29
- return "python3";
30
- }
31
18
 
32
19
  function ensureBinary(): boolean {
33
20
  if (fs.existsSync(RECOGNIZER_BIN)) return true;
@@ -69,17 +56,20 @@ function ensureLinuxReady(ctx: ExtensionContext): boolean {
69
56
  });
70
57
  } catch (err: unknown) {
71
58
  const stderr = (err as { stderr?: Buffer })?.stderr?.toString() ?? "";
72
- if (stderr.includes("sounddevice") || stderr.includes("PortAudio") || stderr.includes("portaudio")) {
73
- ctx.ui.notify("Voice: install libportaudio2 with: sudo apt install libportaudio2", "error");
74
- } else if (stderr.includes("No module") || stderr.includes("ModuleNotFoundError")) {
75
- // Deps missingthe Python script handles auto-install on first run,
76
- // so we let it through. The script's own ensure_deps() will pip install.
77
- ctx.ui.notify("Voice: installing dependencies on first run — this may take a moment", "info");
59
+ const diagnosis = diagnoseSounddeviceError(stderr);
60
+
61
+ if (diagnosis === "missing-module") {
62
+ // Module not installed auto-create venv (handles PEP 668 systems
63
+ // where system pip is blocked). See #2403.
64
+ if (!ensureVoiceVenv({ notify: (msg, level) => ctx.ui.notify(msg, level) })) {
65
+ return false;
66
+ }
78
67
  linuxReady = true;
79
68
  return true;
69
+ } else if (diagnosis === "missing-portaudio") {
70
+ ctx.ui.notify("Voice: install libportaudio2 with: sudo apt install libportaudio2", "error");
80
71
  } else {
81
72
  ctx.ui.notify(`Voice: dependency check failed — ${stderr.split("\n")[0] || "unknown error"}`, "error");
82
- return false;
83
73
  }
84
74
  return false;
85
75
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * linux-ready.ts — Linux voice readiness logic (extracted for testability).
3
+ *
4
+ * Handles:
5
+ * - Detecting system vs venv python3
6
+ * - Diagnosing sounddevice import errors (portaudio vs missing module)
7
+ * - Auto-creating venv on PEP 668 systems
8
+ */
9
+
10
+ import { execFileSync } from "node:child_process";
11
+ import * as fs from "node:fs";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+
15
+ export const VOICE_VENV_DIR = path.join(
16
+ process.env.HOME || process.env.USERPROFILE || os.homedir(),
17
+ ".gsd",
18
+ "voice-venv",
19
+ );
20
+ export const VOICE_VENV_PYTHON = path.join(VOICE_VENV_DIR, "bin", "python3");
21
+
22
+ /** Return the python3 binary path — prefer venv if it exists, else system. */
23
+ export function linuxPython(): string {
24
+ if (fs.existsSync(VOICE_VENV_PYTHON)) return VOICE_VENV_PYTHON;
25
+ return "python3";
26
+ }
27
+
28
+ /**
29
+ * Diagnose a sounddevice import error from its stderr output.
30
+ *
31
+ * Returns:
32
+ * - "missing-module" — sounddevice python package not installed
33
+ * - "missing-portaudio" — libportaudio2 native library not found
34
+ * - "unknown" — unrecognized error
35
+ *
36
+ * IMPORTANT: Check "No module" / "ModuleNotFoundError" BEFORE checking for the
37
+ * word "sounddevice", because `ModuleNotFoundError: No module named 'sounddevice'`
38
+ * contains both strings. The more specific check must come first.
39
+ */
40
+ export function diagnoseSounddeviceError(stderr: string): "missing-module" | "missing-portaudio" | "unknown" {
41
+ // Check for missing Python module FIRST — the error message
42
+ // "ModuleNotFoundError: No module named 'sounddevice'" contains the word
43
+ // "sounddevice", so the old order (checking "sounddevice" first) was wrong.
44
+ if (stderr.includes("No module") || stderr.includes("ModuleNotFoundError")) {
45
+ return "missing-module";
46
+ }
47
+ // Now check for native portaudio library issues.
48
+ if (stderr.includes("PortAudio") || stderr.includes("portaudio")) {
49
+ return "missing-portaudio";
50
+ }
51
+ return "unknown";
52
+ }
53
+
54
+ export interface ReadinessCallbacks {
55
+ notify: (message: string, level: "info" | "error") => void;
56
+ /** Override for execFileSync — for testing. Uses execFileSync (safe, no shell). */
57
+ execFile?: typeof execFileSync;
58
+ /** Override for fs.existsSync — for testing */
59
+ exists?: typeof fs.existsSync;
60
+ }
61
+
62
+ /**
63
+ * Auto-create the voice venv if it doesn't exist.
64
+ * Uses execFileSync internally (no shell, safe from injection).
65
+ *
66
+ * Returns true on success, false on failure.
67
+ */
68
+ export function ensureVoiceVenv(cb: ReadinessCallbacks): boolean {
69
+ const exists = cb.exists ?? fs.existsSync;
70
+ const execFile = cb.execFile ?? execFileSync;
71
+
72
+ if (exists(VOICE_VENV_PYTHON)) return true;
73
+
74
+ cb.notify("Voice: setting up Python environment — one-time setup", "info");
75
+ try {
76
+ execFile("python3", ["-m", "venv", VOICE_VENV_DIR], { timeout: 30000 });
77
+ execFile(
78
+ path.join(VOICE_VENV_DIR, "bin", "pip"),
79
+ ["install", "sounddevice", "requests", "--quiet"],
80
+ { timeout: 120000 },
81
+ );
82
+ return true;
83
+ } catch {
84
+ cb.notify("Voice: failed to create Python venv — run: python3 -m venv ~/.gsd/voice-venv", "error");
85
+ return false;
86
+ }
87
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * linux-ready.test.ts — Tests for Linux voice readiness logic (#2403).
3
+ *
4
+ * Covers:
5
+ * - diagnoseSounddeviceError branch ordering (ModuleNotFoundError must NOT
6
+ * match the portaudio branch, even though it contains "sounddevice")
7
+ * - ensureVoiceVenv auto-creation
8
+ * - linuxPython venv detection
9
+ */
10
+
11
+ import { createTestContext } from "../../gsd/tests/test-helpers.ts";
12
+ import { diagnoseSounddeviceError, ensureVoiceVenv } from "../linux-ready.ts";
13
+
14
+ const { assertEq, assertTrue, report } = createTestContext();
15
+
16
+ function main(): void {
17
+ // ── diagnoseSounddeviceError ──────────────────────────────────────────
18
+
19
+ // The critical regression: "ModuleNotFoundError: No module named 'sounddevice'"
20
+ // contains the word "sounddevice", so the old code matched the portaudio branch.
21
+ console.log("\n=== diagnoseSounddeviceError: ModuleNotFoundError must return missing-module ===");
22
+ {
23
+ const stderr = "Traceback (most recent call last):\n File \"<string>\", line 1, in <module>\nModuleNotFoundError: No module named 'sounddevice'";
24
+ assertEq(diagnoseSounddeviceError(stderr), "missing-module",
25
+ "ModuleNotFoundError for sounddevice should be 'missing-module', not 'missing-portaudio'");
26
+ }
27
+
28
+ console.log("\n=== diagnoseSounddeviceError: 'No module named sounddevice' variant ===");
29
+ {
30
+ const stderr = "ImportError: No module named sounddevice";
31
+ assertEq(diagnoseSounddeviceError(stderr), "missing-module",
32
+ "'No module' substring should return missing-module");
33
+ }
34
+
35
+ console.log("\n=== diagnoseSounddeviceError: actual portaudio error ===");
36
+ {
37
+ const stderr = "OSError: PortAudio library not found";
38
+ assertEq(diagnoseSounddeviceError(stderr), "missing-portaudio",
39
+ "PortAudio library error should return missing-portaudio");
40
+ }
41
+
42
+ console.log("\n=== diagnoseSounddeviceError: lowercase portaudio error ===");
43
+ {
44
+ const stderr = "OSError: libportaudio.so.2: cannot open shared object file: No such file or directory";
45
+ assertEq(diagnoseSounddeviceError(stderr), "missing-portaudio",
46
+ "lowercase portaudio error should return missing-portaudio");
47
+ }
48
+
49
+ console.log("\n=== diagnoseSounddeviceError: unrelated error ===");
50
+ {
51
+ const stderr = "SyntaxError: invalid syntax";
52
+ assertEq(diagnoseSounddeviceError(stderr), "unknown",
53
+ "unrelated error should return unknown");
54
+ }
55
+
56
+ console.log("\n=== diagnoseSounddeviceError: empty stderr ===");
57
+ {
58
+ assertEq(diagnoseSounddeviceError(""), "unknown",
59
+ "empty stderr should return unknown");
60
+ }
61
+
62
+ // ── ensureVoiceVenv ──────────────────────────────────────────────────
63
+
64
+ console.log("\n=== ensureVoiceVenv: returns true when venv already exists ===");
65
+ {
66
+ const notifications: string[] = [];
67
+ const result = ensureVoiceVenv({
68
+ notify: (msg) => notifications.push(msg),
69
+ exists: () => true,
70
+ execFile: (() => Buffer.from("")) as any,
71
+ });
72
+ assertTrue(result, "should return true when venv exists");
73
+ assertEq(notifications.length, 0, "should not notify when venv exists");
74
+ }
75
+
76
+ console.log("\n=== ensureVoiceVenv: creates venv when missing ===");
77
+ {
78
+ const notifications: string[] = [];
79
+ const commands: string[][] = [];
80
+ let existsCalled = false;
81
+
82
+ const result = ensureVoiceVenv({
83
+ notify: (msg) => notifications.push(msg),
84
+ exists: () => { existsCalled = true; return false; },
85
+ execFile: ((cmd: string, args: string[]) => {
86
+ commands.push([cmd, ...args]);
87
+ return Buffer.from("");
88
+ }) as any,
89
+ });
90
+
91
+ assertTrue(result, "should return true after venv creation");
92
+ assertTrue(existsCalled, "should check if venv exists");
93
+ assertEq(commands.length, 2, "should run 2 commands (venv + pip)");
94
+ assertTrue(commands[0][0] === "python3", "first command is python3");
95
+ assertTrue(commands[0].includes("-m") && commands[0].includes("venv"),
96
+ "first command creates venv");
97
+ assertTrue(commands[1][0].endsWith("bin/pip"), "second command is pip");
98
+ assertTrue(commands[1].includes("sounddevice"), "pip installs sounddevice");
99
+ assertTrue(commands[1].includes("requests"), "pip installs requests");
100
+ assertTrue(notifications[0].includes("one-time setup"),
101
+ "notifies about one-time setup");
102
+ }
103
+
104
+ console.log("\n=== ensureVoiceVenv: returns false and notifies on failure ===");
105
+ {
106
+ const notifications: Array<{ msg: string; level: string }> = [];
107
+
108
+ const result = ensureVoiceVenv({
109
+ notify: (msg, level) => notifications.push({ msg, level }),
110
+ exists: () => false,
111
+ execFile: (() => { throw new Error("externally-managed-environment"); }) as any,
112
+ });
113
+
114
+ assertTrue(!result, "should return false on failure");
115
+ const errorNotif = notifications.find(n => n.level === "error");
116
+ assertTrue(errorNotif !== undefined, "should emit error notification");
117
+ assertTrue(errorNotif!.msg.includes("python3 -m venv"),
118
+ "error message should suggest manual venv creation");
119
+ }
120
+
121
+ report();
122
+ }
123
+
124
+ main();