@drakulavich/kesha-voice-kit 2.0.0 → 2.1.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Give your tools a voice — speech to text and back, 25 languages, up to ~19× faster than Whisper. On your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -109,18 +109,18 @@
109
109
  },
110
110
  "kesha": {
111
111
  "engine": {
112
- "version": "2.0.0",
112
+ "version": "2.1.0",
113
113
  "sha256": {
114
- "kesha-engine-darwin-arm64": "2d75bc773fb81917a0dd996124c0e18eea3b15ecc8f04fe274d41c68bda9276c",
115
- "kesha-engine-linux-x64": "09082a1f3eb07d8f8051806a057669f098856d84541f087a1e614ca80d8235b2",
116
- "kesha-engine-windows-x64.exe": "de86f350bfd3d076cf339d99d892605b1a37a99085e8ff4c8ef87057618193f3",
114
+ "kesha-engine-darwin-arm64": "51446285f272a3d5060aa2dd21d2d16a5c31682a8554f29c944fe1af27677008",
115
+ "kesha-engine-linux-x64": "8728b467ca31640739dcbe18e53eadd27e795033075f6442fc14b729e0014a62",
116
+ "kesha-engine-windows-x64.exe": "82515821a6b424ff9c75462721f7723891705a2f1edcba0a1eb5c1704cfadbec",
117
117
  "say-avspeech-darwin-arm64": "a3aa5b75ddff68a48f1158e838310b37cac72ef86841d9cfcdf1ee233cb7451a",
118
118
  "kesha-textlang-darwin-arm64": "e45e2822248b14dd72aa79e2def8fa6b9c07c97423f11c841508769225d72da4"
119
119
  },
120
120
  "size": {
121
- "kesha-engine-darwin-arm64": 73623152,
122
- "kesha-engine-linux-x64": 67392296,
123
- "kesha-engine-windows-x64.exe": 66550272,
121
+ "kesha-engine-darwin-arm64": 73639664,
122
+ "kesha-engine-linux-x64": 67394344,
123
+ "kesha-engine-windows-x64.exe": 66586112,
124
124
  "say-avspeech-darwin-arm64": 64208,
125
125
  "kesha-textlang-darwin-arm64": 57648
126
126
  }
package/src/cli/init.ts CHANGED
@@ -176,14 +176,7 @@ export async function promptInitSelection(
176
176
  };
177
177
  }
178
178
 
179
- /** Mirrors the guard in `performInstall`: a preview must not describe an install this platform rejects (#684). */
180
- async function printPlan(selection: InitSelection): Promise<boolean> {
181
- const backendError = unavailableBackendRefusal(selection.backend);
182
- if (backendError) {
183
- log.error(errorMessage(backendError));
184
- process.exitCode = exitCodeFor(backendError);
185
- return false;
186
- }
179
+ async function printPlan(selection: InitSelection): Promise<void> {
187
180
  log.info(
188
181
  await renderInstallPlan({
189
182
  noCache: selection.noCache,
@@ -193,7 +186,6 @@ async function printPlan(selection: InitSelection): Promise<boolean> {
193
186
  diarize: selection.diarize,
194
187
  }),
195
188
  );
196
- return true;
197
189
  }
198
190
 
199
191
  async function runNonInteractive(selection: InitSelection): Promise<void> {
@@ -203,7 +195,7 @@ async function runNonInteractive(selection: InitSelection): Promise<void> {
203
195
  log.warn("--diarize is currently darwin-arm64 only; omitting it from non-interactive examples.");
204
196
  }
205
197
  log.info(renderInitOverview(canDiarize));
206
- if (!(await printPlan(printableSelection))) return;
198
+ await printPlan(printableSelection);
207
199
  log.info("Run one of these commands from an interactive terminal:");
208
200
  for (const command of initSuggestionCommands(printableSelection, canDiarize)) {
209
201
  log.info(` ${command.join(" ")}`);
@@ -262,6 +254,13 @@ export const initCommand = defineCommand({
262
254
  const noCache = resolveNoCacheFlag(args, rawArgs);
263
255
  const selection = resolveInitSelection(args, backend, noCache);
264
256
 
257
+ const backendError = args.yes && !args.plan ? null : unavailableBackendRefusal(backend);
258
+ if (backendError) {
259
+ log.error(errorMessage(backendError));
260
+ process.exitCode = exitCodeFor(backendError);
261
+ return;
262
+ }
263
+
265
264
  if (args.plan) {
266
265
  log.info(renderInitOverview());
267
266
  await printPlan(selection);
@@ -293,7 +292,7 @@ export const initCommand = defineCommand({
293
292
  noCache,
294
293
  );
295
294
  log.info("");
296
- if (!(await printPlan(prompted))) return;
295
+ await printPlan(prompted);
297
296
  const confirmed = await promptConfirm(
298
297
  `Run \`${initInstallArgs(prompted).join(" ")}\` now?`,
299
298
  true,
@@ -246,7 +246,7 @@ export async function performInstall(options: PerformInstallOptions) {
246
246
  throw backendError;
247
247
  }
248
248
  await installEngine({ noCache, backend, ttsLangs, vad, diarize, version: engineVersion });
249
- await maybeAskForStar(getEngineBinPath(), packageVersion, log);
249
+ if (!log.quietEnabled) await maybeAskForStar(getEngineBinPath(), packageVersion, { info: log.progress, warn: log.warn });
250
250
  finishInstallDiagnostic(diagnosticLog, startedAt, "success");
251
251
  } catch (err: unknown) {
252
252
  const signalExitCode = getPendingSignalExitCode();
@@ -1,5 +1,6 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { errorMessage } from "../error-utils";
3
+ import { exitCodeFor, KeshaError } from "../engine/events";
3
4
  import { createSupportBundle } from "../support-bundle";
4
5
  import { log } from "../log";
5
6
 
@@ -35,7 +36,7 @@ export const supportBundleCommand = defineCommand({
35
36
  log.status(`Size: ${bundle.sizeBytes} bytes`);
36
37
  } catch (err) {
37
38
  log.error(errorMessage(err));
38
- process.exit(1);
39
+ process.exit(err instanceof KeshaError ? exitCodeFor(err) : 1);
39
40
  }
40
41
  },
41
42
  });
@@ -19,6 +19,7 @@ import { runEngineProcess } from "./engine/spawn";
19
19
  import { acquireInstallLock } from "./install-lock";
20
20
  import { log } from "./log";
21
21
  import { engineVersion, injectedEnginePins } from "./package-info";
22
+ import { PATH_ERRNOS } from "./path-errnos";
22
23
  import { keshaCacheDir } from "./paths";
23
24
  import { createLiveStatus, streamResponseToFile } from "./progress";
24
25
  import { interruptedRun } from "./process-tree";
@@ -37,6 +38,8 @@ export {
37
38
  } from "./engine-version-marker";
38
39
 
39
40
  const GITHUB_REPO = "drakulavich/kesha-voice-kit";
41
+ const NETWORK_HINT = "check your network connection and try again.";
42
+ const MISMATCH_HINT = `re-run \`kesha install\`; if the mismatch persists, report it at https://github.com/${GITHUB_REPO}/issues.`;
40
43
 
41
44
  interface ExpectedSha256 {
42
45
  sha256: string;
@@ -51,30 +54,46 @@ export interface AssetPins {
51
54
  }
52
55
 
53
56
  /** Without `--engine-version` nothing but the maintainer can make an unverifiable pinned release verifiable. */
54
- function unverifiableFix(version: string): string {
57
+ function unverifiableFix(version: string, pins: AssetPins | undefined): string {
58
+ if (!pins) {
59
+ return version === engineVersion
60
+ ? `this source checkout installs its own version v${engineVersion}, which may not be released yet: run ` +
61
+ `\`kesha install --engine-version <version>\` with a published release that ships SHA256SUMS (https://github.com/${GITHUB_REPO}/releases).`
62
+ : `pass --engine-version a published release that ships SHA256SUMS (https://github.com/${GITHUB_REPO}/releases).`;
63
+ }
55
64
  return version === engineVersion
56
65
  ? `report it at https://github.com/${GITHUB_REPO}/issues: the pinned engine release v${version} must publish its checksums.`
57
66
  : `pick a release that ships SHA256SUMS (https://github.com/${GITHUB_REPO}/releases), or run ` +
58
67
  `\`kesha install\` without --engine-version to install the pinned v${engineVersion}.`;
59
68
  }
60
69
 
61
- async function fetchSha256Sums(version: string): Promise<Map<string, string>> {
70
+ async function fetchSha256Sums(version: string, pins: AssetPins | undefined): Promise<Map<string, string>> {
62
71
  const url = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/SHA256SUMS`;
63
72
  let res: Response;
64
73
  try {
65
74
  res = await fetch(url, { redirect: "follow" });
66
75
  } catch (e) {
67
- throw new Error(
68
- `Failed to fetch SHA256SUMS for release v${version}: ${errorMessage(e)}\n Fix: Check your network connection and try again`,
69
- );
76
+ throw new KeshaError("E_MODEL_DOWNLOAD", `Failed to fetch SHA256SUMS for release v${version}: ${errorMessage(e)}`, {
77
+ hint: NETWORK_HINT,
78
+ });
70
79
  }
71
80
  if (!res.ok) {
72
- throw new Error(
81
+ throw new KeshaError(
82
+ "E_MODEL_DOWNLOAD",
73
83
  `Cannot verify engine v${version}: release v${version} publishes no SHA256SUMS (HTTP ${res.status}), ` +
74
- `so its binaries cannot be checked before they are installed.\n Fix: ${unverifiableFix(version)}`,
84
+ "so its binaries cannot be checked before they are installed.",
85
+ { hint: unverifiableFix(version, pins) },
75
86
  );
76
87
  }
77
- return parseSha256Sums(await res.text());
88
+ let body: string;
89
+ try {
90
+ body = await res.text();
91
+ } catch (e) {
92
+ throw new KeshaError("E_MODEL_DOWNLOAD", `Failed to read SHA256SUMS for release v${version}: ${errorMessage(e)}`, {
93
+ hint: NETWORK_HINT,
94
+ });
95
+ }
96
+ return parseSha256Sums(body);
78
97
  }
79
98
 
80
99
  /**
@@ -92,12 +111,14 @@ export function releaseChecksums(
92
111
  if (!pinned) throw new Error(`No pinned SHA-256 for ${assetName} of engine v${version}; this CLI build is incomplete.\n Fix: report it at https://github.com/${GITHUB_REPO}/issues.`);
93
112
  return { sha256: pinned, source: "its pinned SHA-256" };
94
113
  }
95
- sums ??= fetchSha256Sums(version);
114
+ sums ??= fetchSha256Sums(version, pins);
96
115
  const sha256 = (await sums).get(assetName);
97
116
  if (!sha256) {
98
- throw new Error(
117
+ throw new KeshaError(
118
+ "E_MODEL_DOWNLOAD",
99
119
  `Cannot verify ${assetName}: the SHA256SUMS of release v${version} does not list it, ` +
100
- `so it cannot be checked before it is installed.\n Fix: ${unverifiableFix(version)}`,
120
+ "so it cannot be checked before it is installed.",
121
+ { hint: unverifiableFix(version, pins) },
101
122
  );
102
123
  }
103
124
  return { sha256, source: `the SHA-256 in the SHA256SUMS of release v${version}` };
@@ -129,25 +150,44 @@ async function rejectMismatchedDownload(
129
150
  return (
130
151
  `${what} from release v${version} does not match ${expected.source}: ` +
131
152
  `expected sha256 ${expected.sha256}, got ${actual}. The download was corrupted or altered, ` +
132
- "so it was deleted instead of installed.\n" +
133
- ` Fix: re-run \`kesha install\`; if the mismatch persists, report it at https://github.com/${GITHUB_REPO}/issues.`
153
+ "so it was deleted instead of installed."
134
154
  );
135
155
  }
136
156
 
137
157
  /**
138
158
  * Whether a cached asset of the pinned release still hashes to its pin; one that does not is
139
- * deleted so the verified download replaces it. `--engine-version` caches are not re-hashed.
159
+ * deleted so the verified download replaces it, or refused when its directory is read-only.
160
+ * `--engine-version` caches are not re-hashed.
140
161
  */
141
- async function cachedAssetMatchesPin(path: string, assetName: string, what: string, version: string): Promise<boolean> {
162
+ async function cachedAssetMatchesPin(
163
+ path: string,
164
+ assetName: string,
165
+ what: string,
166
+ version: string,
167
+ canRepair: boolean,
168
+ ): Promise<boolean> {
142
169
  // KESHA_ENGINE_BIN names the user's own build, which no release pin describes.
143
170
  if (version !== engineChecksums.pins?.version || process.env.KESHA_ENGINE_BIN || !existsSync(path)) return true;
144
171
  const expected = await engineChecksums.forRelease(version)(assetName);
145
- const actual = await sha256OfFile(path);
172
+ let actual: string;
173
+ try {
174
+ actual = await sha256OfFile(path);
175
+ } catch (e) {
176
+ throw canRepair ? e : unreadableInReadOnlyDir(path, e);
177
+ }
146
178
  if (actual === expected.sha256) return true;
147
- log.warn(
148
- `Installed ${what} ${assetName} does not match ${expected.source} (expected sha256 ${expected.sha256}, ` +
149
- `got ${actual}); deleting it and downloading a verified copy.`,
150
- );
179
+ const mismatch =
180
+ `Installed ${what} ${assetName} at ${path} does not match ${expected.source} ` +
181
+ `(expected sha256 ${expected.sha256}, got ${actual})`;
182
+ if (!canRepair) {
183
+ const dir = dirname(path);
184
+ throw new KeshaError("E_CACHE_CORRUPT", `${mismatch}; ${dir} is read-only, so it cannot be replaced.`, {
185
+ hint:
186
+ `make ${dir} writable and re-run \`kesha install\` to replace it with a verified copy, ` +
187
+ "or point KESHA_CACHE_DIR at a writable directory and re-run `kesha install`.",
188
+ });
189
+ }
190
+ log.warn(`${mismatch}; deleting it and downloading a verified copy.`);
151
191
  rmSync(path, { force: true });
152
192
  return false;
153
193
  }
@@ -209,7 +249,7 @@ export function cleanupRetiredSidecars(engineDir: string): string[] {
209
249
  }
210
250
 
211
251
  if (removed.length > 0) {
212
- log.success(`Removed retired sidecars: ${removed.join(", ")}.`);
252
+ log.progress(`Removed retired sidecars: ${removed.join(", ")}.`);
213
253
  }
214
254
 
215
255
  return removed;
@@ -320,12 +360,12 @@ async function downloadSidecar(
320
360
  await streamResponseToFile(res, sidecarPath, spec.displayName);
321
361
  const refusal = await rejectMismatchedDownload(sidecarPath, `${spec.displayName} ${spec.assetName}`, version, expected);
322
362
  if (refusal) {
323
- log.warn(`${refusal}\n ${spec.displayName} not installed; ${spec.unavailableHint}.`);
363
+ log.warn(`${refusal}\n Fix: ${MISMATCH_HINT}\n ${spec.displayName} not installed; ${spec.unavailableHint}.`);
324
364
  return;
325
365
  }
326
366
  chmodSync(sidecarPath, 0o755);
327
367
  darwinTrustBinary(sidecarPath, spec.displayName);
328
- log.success(`${spec.displayName} installed (${spec.availableHint}).`);
368
+ log.progress(`${spec.displayName} installed (${spec.availableHint}).`);
329
369
  } catch (e) {
330
370
  log.warn(
331
371
  `${spec.displayName} install failed (${errorMessage(e)}); ${spec.unavailableHint}.`,
@@ -376,7 +416,7 @@ export async function warmDarwinKokoro(binPath: string, timeoutMs = 180_000): Pr
376
416
  return;
377
417
  }
378
418
 
379
- log.success(
419
+ log.progress(
380
420
  `FluidAudio Kokoro warmed (${Math.round(performance.now() - startedAt)}ms).`,
381
421
  );
382
422
  } catch (e) {
@@ -446,19 +486,18 @@ async function refreshCachedEngine(
446
486
  ): Promise<void> {
447
487
  const engineDir = dirname(binPath);
448
488
  if (noCache && !canWriteEngineDir) {
449
- log.info(
489
+ log.warn(
450
490
  `Engine binary at v${version} is on a read-only filesystem; --no-cache skipped for engine (still forwarded to model installs).`,
451
491
  );
452
492
  } else {
453
- log.success(`Engine binary already installed (v${version}).`);
493
+ log.progress(`Engine binary already installed (v${version}).`);
454
494
  }
455
495
  // Re-trust on cache hit: a user who upgraded to Sequoia after install would still have
456
496
  // com.apple.provenance attached; idempotent (~10ms no-op if already correct).
457
497
  if (canWriteEngineDir && existsSync(binPath)) {
458
498
  darwinTrustBinary(binPath, "kesha-engine binary");
459
499
  }
460
- // Top up missing or broken sidecars (pre-#141/#199 cached binaries never had them);
461
- // skip on read-only fs (Nix-store) to avoid confusing "install failed" warnings.
500
+ // Top up missing or broken sidecars (pre-#141/#199 cached binaries never had them).
462
501
  if (canWriteEngineDir) {
463
502
  const checksums = engineChecksums.forRelease(version);
464
503
  await Promise.all(
@@ -468,7 +507,7 @@ async function refreshCachedEngine(
468
507
  // sidecar is SIGKILLed on spawn, and re-downloading it would not lift the block.
469
508
  let pinned = false;
470
509
  try {
471
- pinned = existsSync(path) && (await cachedAssetMatchesPin(path, spec.assetName, spec.displayName, version));
510
+ pinned = existsSync(path) && (await cachedAssetMatchesPin(path, spec.assetName, spec.displayName, version, true));
472
511
  } catch (e) {
473
512
  log.warn(`Could not read ${spec.displayName} at ${path} (${errorMessage(e)}); removing it and downloading it again.`);
474
513
  rmSync(path, { force: true });
@@ -479,6 +518,10 @@ async function refreshCachedEngine(
479
518
  }
480
519
  }),
481
520
  );
521
+ } else {
522
+ for (const spec of SIDECARS) {
523
+ await cachedAssetMatchesPin(join(engineDir, spec.fileBasename), spec.assetName, spec.displayName, version, false);
524
+ }
482
525
  }
483
526
  }
484
527
 
@@ -602,32 +645,35 @@ async function fetchEngineBinary(
602
645
  res = await fetch(url, { redirect: "follow" });
603
646
  } catch (e) {
604
647
  muteSidecarRejections();
605
- throw new Error(
606
- `Failed to fetch engine binary: ${errorMessage(e)}\n Fix: Check your network connection and try again`,
607
- );
648
+ throw new KeshaError("E_MODEL_DOWNLOAD", `Failed to fetch engine binary: ${errorMessage(e)}`, { hint: NETWORK_HINT });
608
649
  }
609
650
 
610
651
  if (!res.ok) {
611
652
  muteSidecarRejections();
612
653
  // Names the tag: nothing falls back to the pin, so the caller must see which release 404ed.
613
- throw new Error(
614
- `Failed to download engine binary from release v${version} (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
615
- );
654
+ throw new KeshaError("E_MODEL_DOWNLOAD", `Failed to download engine binary from release v${version} (HTTP ${res.status})`, {
655
+ hint: `check https://github.com/${GITHUB_REPO}/releases for available versions.`,
656
+ });
616
657
  }
617
658
 
618
- await streamResponseToFile(res, binPath, "kesha-engine binary");
659
+ try {
660
+ await streamResponseToFile(res, binPath, "kesha-engine binary");
661
+ } catch (e) {
662
+ muteSidecarRejections();
663
+ throw e instanceof KeshaError ? e : (engineWriteFailure(binPath, e) ?? e);
664
+ }
619
665
  const refusal = await rejectMismatchedDownload(binPath, `kesha-engine binary ${binaryName}`, version, expected);
620
666
  if (refusal) {
621
667
  muteSidecarRejections();
622
668
  rmSync(getVersionMarkerPath(binPath), { force: true });
623
- throw new Error(refusal);
669
+ throw new KeshaError("E_CACHE_CORRUPT", refusal, { hint: MISMATCH_HINT });
624
670
  }
625
671
  chmodSync(binPath, 0o755);
626
672
  darwinTrustBinary(binPath, "kesha-engine binary");
627
673
  // Marker last: writing it first sends the retry down the cacheValid branch, which never waits.
628
674
  await waitUntilSpawnable(binPath);
629
675
  writeInstalledEngineVersion(binPath, version);
630
- log.success(`Engine binary downloaded (v${version}).`);
676
+ log.progress(`Engine binary downloaded (v${version}).`);
631
677
  await Promise.all(sidecarPromises);
632
678
  }
633
679
 
@@ -807,17 +853,28 @@ async function assertRequestedVersionLanded(binPath: string, version: string): P
807
853
  );
808
854
  }
809
855
 
810
- /** mkdir errnos that mean the configured path is itself wrong; anything else is not a bad argument. */
811
- const ENGINE_DIR_PATH_ERRNOS: Record<string, string> = {
812
- EACCES: "permission denied",
813
- EPERM: "permission denied",
814
- ENOTDIR: "a component of it is a file, not a directory",
815
- // mkdir(recursive) raises this, not ENOTDIR, when the engine directory itself is an existing file.
816
- EEXIST: "it already exists as a file, not a directory",
817
- EROFS: "the filesystem is read-only",
818
- ELOOP: "the path loops through symlinks",
819
- ENAMETOOLONG: "the path is too long",
820
- };
856
+ function engineDirSetting(): { name: string; value: string } | null {
857
+ if (process.env.KESHA_ENGINE_BIN) return { name: "KESHA_ENGINE_BIN", value: process.env.KESHA_ENGINE_BIN };
858
+ if (process.env.KESHA_CACHE_DIR) return { name: "KESHA_CACHE_DIR", value: process.env.KESHA_CACHE_DIR };
859
+ if (process.env.KESHA_HOME) return { name: "KESHA_HOME", value: process.env.KESHA_HOME };
860
+ return null;
861
+ }
862
+
863
+ function unreadableInReadOnlyDir(path: string, e: unknown): unknown {
864
+ const errno = (e as NodeJS.ErrnoException).code ?? "";
865
+ const why = PATH_ERRNOS[errno];
866
+ if (!why) return e;
867
+ const dir = dirname(path);
868
+ return new KeshaError(
869
+ "E_INVALID_ARG",
870
+ `Cannot verify ${path} against its pinned SHA-256: ${why} (${errno}), and ${dir} is read-only, so it cannot be replaced.`,
871
+ {
872
+ hint:
873
+ `make ${path} readable and ${dir} writable, then re-run \`kesha install\`. Or ` +
874
+ engineDirFix(engineDirSetting()?.name, false, dir),
875
+ },
876
+ );
877
+ }
821
878
 
822
879
  /** `KESHA_ENGINE_BIN` names the binary file, so "point it at a writable directory" breaks the next install. */
823
880
  function engineDirFix(setting: string | undefined, notADir: boolean, engineDir: string): string {
@@ -847,15 +904,9 @@ function ensureEngineDirCreatable(binPath: string): void {
847
904
  try {
848
905
  mkdirSync(engineDir, { recursive: true });
849
906
  } catch (e) {
850
- const setting = process.env.KESHA_ENGINE_BIN
851
- ? { name: "KESHA_ENGINE_BIN", value: process.env.KESHA_ENGINE_BIN }
852
- : process.env.KESHA_CACHE_DIR
853
- ? { name: "KESHA_CACHE_DIR", value: process.env.KESHA_CACHE_DIR }
854
- : process.env.KESHA_HOME
855
- ? { name: "KESHA_HOME", value: process.env.KESHA_HOME }
856
- : null;
907
+ const setting = engineDirSetting();
857
908
  const errno = (e as NodeJS.ErrnoException).code ?? "";
858
- const why = ENGINE_DIR_PATH_ERRNOS[errno];
909
+ const why = PATH_ERRNOS[errno];
859
910
  const what = setting
860
911
  ? `${setting.name}="${setting.value}" cannot hold the engine directory ${engineDir}: ${why ?? errorMessage(e)}`
861
912
  : `cannot create the engine directory ${engineDir}: ${why ?? errorMessage(e)}`;
@@ -871,6 +922,28 @@ function ensureEngineDirCreatable(binPath: string): void {
871
922
  }
872
923
  }
873
924
 
925
+ function engineWriteFailure(binPath: string, e: unknown): KeshaError | null {
926
+ const engineDir = dirname(binPath);
927
+ const errno = (e as NodeJS.ErrnoException).code ?? "";
928
+ const setting = engineDirSetting();
929
+ const into = setting ? `${engineDir} (from ${setting.name}="${setting.value}")` : engineDir;
930
+ const why = PATH_ERRNOS[errno];
931
+ if (why) {
932
+ return new KeshaError("E_INVALID_ARG", `Cannot write the engine binary into ${into}: ${why}`, {
933
+ hint: engineDirFix(setting?.name, errno === "ENOTDIR" || errno === "EEXIST", engineDir),
934
+ });
935
+ }
936
+ if (!errno) return null;
937
+ if (errno === "ENOSPC") {
938
+ return new KeshaError("E_INTERNAL", `Cannot write the engine binary into ${into}: no space left on its disk (ENOSPC)`, {
939
+ hint: `the disk is full: free space on the volume that holds ${engineDir}, then re-run \`kesha install\`.`,
940
+ });
941
+ }
942
+ return new KeshaError("E_INTERNAL", `Cannot write the engine binary into ${into}: ${errorMessage(e)}`, {
943
+ hint: `resolve that filesystem error on ${engineDir} and re-run \`kesha install\`; if it persists, file a bug with \`kesha support-bundle\`.`,
944
+ });
945
+ }
946
+
874
947
  export interface EngineInstallRequest extends InstallOptions {
875
948
  noCache?: boolean;
876
949
  backend?: string;
@@ -913,7 +986,7 @@ async function installLockedEngine(
913
986
  const engineDir = dirname(binPath);
914
987
 
915
988
  if (version !== engineVersion) {
916
- log.info(
989
+ log.warn(
917
990
  `Installing engine v${version} instead of the pinned v${engineVersion}; ` +
918
991
  "a later `kesha install` without --engine-version restores the pin.",
919
992
  );
@@ -923,15 +996,14 @@ async function installLockedEngine(
923
996
  const canWriteEngineDir = checkEngineWritable(engineDir);
924
997
 
925
998
  const markerMatches = existsSync(binPath) && installedVersion === version;
926
- // The marker vouches for a file, not for a working binary. Skipped on a read-only engine
927
- // dir: nothing there can be repaired, so a failed probe would only turn a usable Nix
999
+ // The marker vouches for a file, not for a working binary. The probe is skipped on a read-only
1000
+ // engine dir: nothing there can be repaired, so a failed probe would only turn a usable Nix
928
1001
  // install into a hard error.
929
1002
  // Hashed before the health probe, so an altered binary is never run.
930
1003
  const versionMatches =
931
1004
  markerMatches &&
932
- (!canWriteEngineDir ||
933
- ((await cachedAssetMatchesPin(binPath, getEngineBinaryName(), "kesha-engine binary", version)) &&
934
- (await engineWorks(binPath))));
1005
+ (await cachedAssetMatchesPin(binPath, getEngineBinaryName(), "kesha-engine binary", version, canWriteEngineDir)) &&
1006
+ (!canWriteEngineDir || (await engineWorks(binPath)));
935
1007
  // On read-only fs, --no-cache can't re-download; treat as cache-valid and forward flag to model install.
936
1008
  const cacheValid = versionMatches && (!noCache || !canWriteEngineDir);
937
1009
 
@@ -0,0 +1,11 @@
1
+ /** Errnos that mean the path itself is wrong for writing, so the fix is a different path; anything else is not a bad argument. */
2
+ export const PATH_ERRNOS: Record<string, string> = {
3
+ EACCES: "permission denied",
4
+ EPERM: "permission denied",
5
+ ENOTDIR: "a component of it is a file, not a directory",
6
+ // mkdir(recursive) raises this, not ENOTDIR, when the directory itself is an existing file.
7
+ EEXIST: "it already exists as a file, not a directory",
8
+ EROFS: "the filesystem is read-only",
9
+ ELOOP: "the path loops through symlinks",
10
+ ENAMETOOLONG: "the path is too long",
11
+ };
package/src/progress.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { copyFileSync, readdirSync, renameSync, rmSync, statSync } from "fs";
2
2
  import { basename, dirname, join } from "path";
3
3
  import { errorMessage } from "./error-utils";
4
+ import { KeshaError } from "./engine/events";
4
5
  import { log } from "./log";
5
6
 
6
7
  const BAR_WIDTH = 20;
@@ -172,11 +173,15 @@ export async function applyBackpressure(
172
173
  */
173
174
  const STALE_STAGING_MS = 24 * 60 * 60 * 1000;
174
175
 
176
+ export function stagingPrefix(destPath: string): string {
177
+ return `${destPath}.part.`;
178
+ }
179
+
175
180
  /** A Ctrl-C kills the process before any cleanup runs, so last run's staging file is swept by the next one (#770). */
176
181
  function sweepStagingFiles(destPath: string): void {
177
182
  if (process.platform === "win32") return;
178
183
  const dir = dirname(destPath);
179
- const prefix = `${basename(destPath)}.part.`;
184
+ const prefix = basename(stagingPrefix(destPath));
180
185
  const cutoffMs = Date.now() - STALE_STAGING_MS;
181
186
 
182
187
  let names: string[];
@@ -225,32 +230,44 @@ export async function streamResponseToFile(
225
230
  destPath: string,
226
231
  label: string,
227
232
  ): Promise<number> {
228
- if (!res.body) {
229
- throw new Error(
230
- `Download failed: empty response for ${label}\n Fix: Try again — the server may be temporarily unavailable`,
231
- );
232
- }
233
+ const emptyResponse = () =>
234
+ new KeshaError("E_MODEL_DOWNLOAD", `Download failed: empty response for ${label}`, {
235
+ hint: "try again; the server may be temporarily unavailable.",
236
+ });
237
+ if (!res.body) throw emptyResponse();
233
238
 
234
239
  const totalBytes = Number(res.headers.get("content-length") || 0);
235
240
  const progress = createProgressBar(label, totalBytes);
236
241
 
237
242
  sweepStagingFiles(destPath);
238
- const stagingPath = `${destPath}.part.${process.pid}.${stagingSeq++}`;
239
- const writer = Bun.file(stagingPath).writer();
243
+ const stagingPath = `${stagingPrefix(destPath)}${process.pid}.${stagingSeq++}`;
244
+ const reader = res.body.getReader();
240
245
  let bytes = 0;
241
246
  try {
247
+ const writer = Bun.file(stagingPath).writer();
242
248
  try {
243
- for await (const chunk of res.body) {
244
- await applyBackpressure(writer, writer.write(chunk));
245
- bytes += chunk.length;
246
- progress.update(chunk.length);
249
+ for (;;) {
250
+ let next: Awaited<ReturnType<typeof reader.read>>;
251
+ try {
252
+ next = await reader.read();
253
+ } catch (err) {
254
+ throw new KeshaError("E_MODEL_DOWNLOAD", `Download of ${label} broke off after ${bytes} bytes: ${errorMessage(err)}`, {
255
+ hint: "check your network connection and try again.",
256
+ });
257
+ }
258
+ if (next.done) break;
259
+ await applyBackpressure(writer, writer.write(next.value));
260
+ bytes += next.value.length;
261
+ progress.update(next.value.length);
247
262
  }
248
263
  } finally {
249
264
  // Must await: an open write handle makes the freshly-downloaded engine unspawnable — EBUSY on Windows, ETXTBSY on Linux.
250
265
  await writer.end();
251
266
  }
267
+ if (bytes === 0) throw emptyResponse();
252
268
  publishStaging(stagingPath, destPath);
253
269
  } catch (err) {
270
+ reader.cancel().catch(() => {});
254
271
  rmSync(stagingPath, { force: true });
255
272
  throw err;
256
273
  }
@@ -272,7 +289,7 @@ export function createProgressBar(label: string, totalBytes: number): {
272
289
  return {
273
290
  update() {},
274
291
  finish() {
275
- log.success(`Downloaded ${label} ✓`);
292
+ log.progress(`Downloaded ${label} ✓`);
276
293
  },
277
294
  };
278
295
  }
@@ -1,8 +1,11 @@
1
- import { existsSync, mkdirSync, statSync, writeFileSync } from "fs";
2
- import { basename, dirname, resolve } from "path";
1
+ import { lstatSync, mkdirSync, statSync, writeFileSync } from "fs";
2
+ import { basename, dirname, join, resolve } from "path";
3
3
  import { gzipSync } from "node:zlib";
4
4
  import { collectDoctorReport, formatDoctorReport } from "./doctor";
5
5
  import { readDiagnosticLogTail } from "./diagnostic-log";
6
+ import { KeshaError } from "./engine/events";
7
+ import { errorMessage } from "./error-utils";
8
+ import { PATH_ERRNOS } from "./path-errnos";
6
9
 
7
10
  interface SupportBundleOptions {
8
11
  output?: string;
@@ -99,6 +102,66 @@ function createTarArchive(entries: TarEntry[]): Uint8Array {
99
102
  return archive;
100
103
  }
101
104
 
105
+ function existsAsLink(path: string): boolean {
106
+ try {
107
+ return lstatSync(path).isSymbolicLink();
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ function fileInTheWay(dir: string): string | null {
114
+ for (let d = dir; ; d = dirname(d)) {
115
+ try {
116
+ return statSync(d).isDirectory() ? null : d;
117
+ } catch (e) {
118
+ if ((e as NodeJS.ErrnoException).code === "ENOENT" && existsAsLink(d)) return d;
119
+ if (dirname(d) === d) return null;
120
+ }
121
+ }
122
+ }
123
+
124
+ function mkdirReason(dir: string, errno: string): string {
125
+ if (errno !== "EEXIST") return errno;
126
+ try {
127
+ statSync(dir);
128
+ return errno;
129
+ } catch (e) {
130
+ return (e as NodeJS.ErrnoException).code ?? errno;
131
+ }
132
+ }
133
+
134
+ function bundleWriteFailure(outputPath: string, e: unknown): unknown {
135
+ const dir = dirname(outputPath);
136
+ const errno = mkdirReason(dir, (e as NodeJS.ErrnoException).code ?? "");
137
+ const blocker = fileInTheWay(dir);
138
+ if (blocker) {
139
+ return new KeshaError("E_INVALID_ARG", `Cannot write the support bundle to ${outputPath}: ${blocker} exists and is not a directory`, {
140
+ hint: `remove or rename the file blocking ${blocker}, or pass --output a path under a directory.`,
141
+ });
142
+ }
143
+ if (errno === "EISDIR") {
144
+ return new KeshaError("E_INVALID_ARG", `Cannot write the support bundle to ${outputPath}: it is a directory (EISDIR)`, {
145
+ hint: `pass --output a .tar.gz file path, for example ${join(outputPath, "kesha-support-bundle.tar.gz")}.`,
146
+ });
147
+ }
148
+ const why = PATH_ERRNOS[errno];
149
+ if (why) {
150
+ return new KeshaError("E_INVALID_ARG", `Cannot write the support bundle to ${outputPath}: ${why} (${errno})`, {
151
+ hint: `pass --output a path in a directory you can write to.`,
152
+ });
153
+ }
154
+ if (!errno) return e;
155
+ if (errno === "ENOSPC") {
156
+ return new KeshaError("E_INTERNAL", `Cannot write the support bundle to ${outputPath}: no space left on its disk (ENOSPC)`, {
157
+ hint: `the disk is full: free space on the volume that holds ${dir}, or pass --output a path on another disk.`,
158
+ });
159
+ }
160
+ return new KeshaError("E_INTERNAL", `Cannot write the support bundle to ${outputPath}: ${errorMessage(e)}`, {
161
+ hint: `resolve that filesystem error on ${dir}, or pass --output a path elsewhere.`,
162
+ });
163
+ }
164
+
102
165
  export async function createSupportBundle(
103
166
  options: SupportBundleOptions = {},
104
167
  ): Promise<SupportBundleResult> {
@@ -180,16 +243,17 @@ export async function createSupportBundle(
180
243
  );
181
244
  }
182
245
 
183
- mkdirSync(dirname(outputPath), { recursive: true });
184
- writeFileSync(outputPath, gzipSync(createTarArchive(files)));
185
-
186
- if (!existsSync(outputPath)) {
187
- throw new Error(`failed to create support bundle: ${outputPath}`);
246
+ const archive = gzipSync(createTarArchive(files));
247
+ try {
248
+ mkdirSync(dirname(outputPath), { recursive: true });
249
+ writeFileSync(outputPath, archive);
250
+ } catch (e) {
251
+ throw bundleWriteFailure(outputPath, e);
188
252
  }
189
253
 
190
254
  return {
191
255
  path: outputPath,
192
- sizeBytes: statSync(outputPath).size,
256
+ sizeBytes: archive.byteLength,
193
257
  entries: files.map((file) => file.name),
194
258
  };
195
259
  }