fix(media-use): say to fix an unreadable credentials path, not to log in again (#4998)

* fix(media-use): say to fix an unreadable credentials path, not to log in again

* fix(media-use): provider=heygen gives the same fix hint as the auth headers

* test(media-use): restore env in the pickProvider test with a plain if
This commit is contained in:
Miguel Ángel
2026-10-04 10:25:47 +00:00
committed by GitHub
parent 44b5836250
commit 981bcf4553
5 changed files with 76 additions and 13 deletions
+1 -1
View File
@@ -54,7 +54,7 @@
"files": 1
},
"media-use": {
"hash": "08bff9e1bf006d4b",
"hash": "92e7e0e170665b11",
"files": 104
},
"motion-graphics": {
+16 -6
View File
@@ -59,23 +59,29 @@ export function loadEnvFromDir(startDir) {
// → { headers } | { expired: true } | null. Never throws.
export function heygenCredential() {
const cred = resolveCredential();
return cred?.unreadable ? null : cred;
}
// heygenCredential's answer, or { unreadable: { file, code } } when the credentials path exists but cannot be read
// (a folder, a locked ~/.heygen), so heygenAuthHeaders can say to fix that path: logging in again would fail there too.
// Read without checking first, so the file cannot change between a check and the read.
function resolveCredential() {
const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
if (envKey) return { headers: { "X-Api-Key": envKey } };
const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
// Callers only probe for a credential, so any path that cannot be read (missing, a folder, a locked ~/.heygen)
// is none. Read without checking first, so the file cannot change between a check and the read.
let raw;
try {
raw = readFileSync(file, "utf8").trim();
} catch {
return null;
} catch (error) {
return error.code === "ENOENT" ? null : { unreadable: { file, code: error.code } };
}
if (!raw) return null;
if (!raw.startsWith("{")) return { headers: { "X-Api-Key": raw } };
// A malformed credentials file (partial write / wrong shape) must degrade to
// "no credential", not crash the engine at startup — this function never throws.
// "no credential", not crash the engine at startup.
let cred;
try {
cred = JSON.parse(raw);
@@ -105,7 +111,7 @@ export function heygenAuthMethod() {
// → auth headers object, or throw with a fix hint.
export function heygenAuthHeaders() {
const cred = heygenCredential();
const cred = resolveCredential();
if (cred?.headers) {
// Only tag OAuth (Bearer) traffic as cli-source — the backend uses it to
// grant the free allowance for OAuth requests and ignores it for API-key
@@ -115,6 +121,10 @@ export function heygenAuthHeaders() {
? { ...cred.headers, ...HEYGEN_CLI_SOURCE_HEADERS, ...HEYGEN_CLIENT_SOURCE_HEADERS }
: { ...cred.headers, ...HEYGEN_CLIENT_SOURCE_HEADERS };
}
if (cred?.unreadable)
throw new Error(
`HeyGen credentials at ${cred.unreadable.file} can't be read (${cred.unreadable.code}) — fix or remove that path, then run \`npx hyperframes auth login\``,
);
if (cred?.expired)
throw new Error(
"HeyGen OAuth token expired — run `npx hyperframes auth refresh` (or `npx hyperframes auth login`)",
@@ -3,7 +3,12 @@ import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { heygenAuthHeaders, heygenAuthMethod, loadEnvFromDir } from "./heygen.mjs";
import {
heygenAuthHeaders,
heygenAuthMethod,
heygenCredential,
loadEnvFromDir,
} from "./heygen.mjs";
function withCleanHeygenEnv(fn) {
const previousApiKey = process.env.HEYGEN_API_KEY;
@@ -141,3 +146,23 @@ test("heygenAuthMethod returns null when the credentials path is a symlink loop"
}
});
});
test("heygenAuthHeaders says to fix an unreadable credentials path, and to log in when there is none", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
assert.throws(() => heygenAuthHeaders(), /no HeyGen credentials/);
mkdirSync(join(dir, "credentials"));
assert.equal(heygenCredential(), null);
assert.throws(
() => heygenAuthHeaders(),
(error) =>
error.message.includes(join(dir, "credentials")) &&
/fix or remove that path/.test(error.message),
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+2 -4
View File
@@ -45,10 +45,8 @@ export function pickProvider(userProvider) {
throw new Error(
"provider=gemini needs GEMINI_API_KEY or GOOGLE_API_KEY, or service-account credentials (GOOGLE_APPLICATION_CREDENTIALS or GCS_CREDS)",
);
if (userProvider === "heygen" && !heygenAvailable())
throw new Error(
"provider=heygen but no HeyGen credentials (set $HEYGEN_API_KEY or run `npx hyperframes auth login`)",
);
// heygenAuthHeaders owns the reason and its fix: no credential, or a credentials path that cannot be read.
if (userProvider === "heygen" && !heygenAvailable()) heygenAuthHeaders();
if (userProvider === "elevenlabs" && !process.env.ELEVENLABS_API_KEY)
throw new Error("provider=elevenlabs but $ELEVENLABS_API_KEY is not set");
return userProvider;
@@ -1,6 +1,14 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, writeFileSync, chmodSync, rmSync, existsSync } from "node:fs";
import {
mkdirSync,
mkdtempSync,
readFileSync,
writeFileSync,
chmodSync,
rmSync,
existsSync,
} from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import {
@@ -9,6 +17,7 @@ import {
synthesizeOne,
synthesizeHeygen,
synthResult,
pickProvider,
} from "./tts.mjs";
test("parseFfmpegDurationBanner reads ffmpeg's stderr Duration line", () => {
@@ -198,3 +207,24 @@ test(
assert.ok(!(await kokoroArgv({})).includes("--speed"));
},
);
test("pickProvider heygen with an unreadable credentials path says to fix that path", () => {
const saved = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"].map((k) => [
k,
process.env[k],
]);
const dir = mkdtempSync(join(tmpdir(), "tts-cred-"));
try {
delete process.env.HEYGEN_API_KEY;
delete process.env.HYPERFRAMES_API_KEY;
process.env.HEYGEN_CONFIG_DIR = dir;
mkdirSync(join(dir, "credentials"));
assert.throws(() => pickProvider("heygen"), /fix or remove that path/);
} finally {
for (const [k, v] of saved) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
rmSync(dir, { recursive: true, force: true });
}
});