diff --git a/skills-manifest.json b/skills-manifest.json index 0775525b7..e0d0eeecf 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -38,8 +38,8 @@ "files": 3 }, "hyperframes-media": { - "hash": "cefef0080fc780df", - "files": 43 + "hash": "eb72d0765185b36d", + "files": 44 }, "hyperframes-registry": { "hash": "e3b389526834109d", diff --git a/skills/hyperframes-media/scripts/lib/tts.mjs b/skills/hyperframes-media/scripts/lib/tts.mjs index 185c874fd..e7cbb6853 100644 --- a/skills/hyperframes-media/scripts/lib/tts.mjs +++ b/skills/hyperframes-media/scripts/lib/tts.mjs @@ -25,7 +25,9 @@ export function heygenAvailable() { } export function elevenlabsAvailable() { if (!process.env.ELEVENLABS_API_KEY) return false; - const r = spawnSync("python3", ["-c", "import elevenlabs"], { stdio: "ignore" }); + const r = spawnSync("python3", ["-c", "import elevenlabs"], { + stdio: "ignore", + }); return r.status === 0; } @@ -72,7 +74,12 @@ export async function resolveVoiceId({ provider, userVoice, lang = "en" }) { // ── helpers ───────────────────────────────────────────────────────────────── export function withWordIds(words) { - return (words ?? []).map((w, i) => ({ id: `w${i}`, text: w.text, start: w.start, end: w.end })); + return (words ?? []).map((w, i) => ({ + id: `w${i}`, + text: w.text, + start: w.start, + end: w.end, + })); } // `ffmpeg -i ` prints a `Duration: HH:MM:SS.ms` line to stderr even @@ -108,9 +115,57 @@ export function ffprobeDuration(absPath) { return parseFloat(String(r.stdout).trim()); } -function spawnP(cmd, args, opts) { +export function resolveNpxCliFromNpmExecPath( + npmExecPath = process.env.npm_execpath, + pathExists = existsSync, +) { + if (!npmExecPath) return null; + const fileName = npmExecPath.replace(/\\/g, "/").split("/").pop()?.toLowerCase(); + const npxCliPath = + fileName === "npx-cli.js" ? npmExecPath : join(dirname(npmExecPath), "npx-cli.js"); + return pathExists(npxCliPath) ? npxCliPath : null; +} + +export function resolveSpawnCommand( + cmd, + args, + opts = {}, + platform = process.platform, + env = process.env, + pathExists = existsSync, +) { + if (cmd !== "npx" || platform !== "win32") { + return { cmd, args, opts: { stdio: "ignore", ...opts } }; + } + + // On Windows, npx resolves to npx.cmd, which Node cannot execute directly. + // Avoid `shell:true` and the .cmd shim entirely by invoking npm's JS CLI with + // node, preserving request-provided values as argv data instead of shell text. + const npxCliPath = resolveNpxCliFromNpmExecPath(env.npm_execpath, pathExists); + if (!npxCliPath) return null; + return { + cmd: env.npm_node_execpath || process.execPath, + args: [npxCliPath, ...args.map((arg) => String(arg))], + opts: { stdio: "ignore", windowsHide: true, ...opts }, + }; +} + +// `platform`/`spawnFn` params (default process.platform / the real spawn) +// exist so tests can exercise the win32 branch without mocking node:child_process +// (its ESM exports are non-configurable, so mock.method can't patch it). +export function spawnP( + cmd, + args, + opts = {}, + platform = process.platform, + spawnFn = spawn, + env = process.env, + pathExists = existsSync, +) { + const resolved = resolveSpawnCommand(cmd, args, opts, platform, env, pathExists); + if (!resolved) return Promise.resolve({ status: -1 }); return new Promise((resolve) => { - const p = spawn(cmd, args, { stdio: "ignore", ...opts }); + const p = spawnFn(resolved.cmd, resolved.args, resolved.opts); p.on("exit", (code) => resolve({ status: code ?? -1 })); p.on("error", () => resolve({ status: -1 })); }); diff --git a/skills/hyperframes-media/scripts/lib/tts.spawn.test.mjs b/skills/hyperframes-media/scripts/lib/tts.spawn.test.mjs new file mode 100644 index 000000000..f7a6680ea --- /dev/null +++ b/skills/hyperframes-media/scripts/lib/tts.spawn.test.mjs @@ -0,0 +1,97 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { resolveNpxCliFromNpmExecPath, resolveSpawnCommand, spawnP } from "./tts.mjs"; + +// Regression: on Windows, npx resolves to npx.cmd, which spawn() cannot exec +// without shell:true — it fails ENOENT, silently swallowed as ok:false by the +// caller. spawnP takes injectable platform/spawnFn params so this doesn't +// need to touch the real process.platform or mock node:child_process (whose +// ESM exports are non-configurable). +function fakeSpawn(captured) { + return (cmd, args, opts) => { + captured.push({ cmd, args, opts }); + const p = new EventEmitter(); + setImmediate(() => p.emit("exit", 0)); + return p; + }; +} + +const envWithNpxCli = { + npm_execpath: "/opt/node/lib/node_modules/npm/bin/npm-cli.js", + npm_node_execpath: "/opt/node/bin/node", +}; +const npxCliPath = "/opt/node/lib/node_modules/npm/bin/npx-cli.js"; +const pathExists = (path) => path === npxCliPath; + +test("resolveNpxCliFromNpmExecPath finds npx-cli next to npm-cli", () => { + assert.equal(resolveNpxCliFromNpmExecPath(envWithNpxCli.npm_execpath, pathExists), npxCliPath); +}); + +test("resolveSpawnCommand routes npx through node+npx-cli on win32 without shell:true", () => { + const resolved = resolveSpawnCommand( + "npx", + ["hyperframes", "tts", "C:\\Users\\Test User\\line.txt", "--voice", "am_michael"], + {}, + "win32", + envWithNpxCli, + pathExists, + ); + assert.ok(resolved); + assert.equal(resolved.cmd, envWithNpxCli.npm_node_execpath); + assert.deepEqual(resolved.args, [ + npxCliPath, + "hyperframes", + "tts", + "C:\\Users\\Test User\\line.txt", + "--voice", + "am_michael", + ]); + assert.equal(resolved.opts.shell, undefined); +}); + +test("resolveSpawnCommand preserves Windows npx shell metacharacters as argv data", () => { + const resolved = resolveSpawnCommand( + "npx", + ["hyperframes", "tts", "hello & calc"], + {}, + "win32", + envWithNpxCli, + pathExists, + ); + assert.ok(resolved); + assert.deepEqual(resolved.args, [npxCliPath, "hyperframes", "tts", "hello & calc"]); +}); + +test("spawnP uses the resolved node+npx-cli command for npx on win32", async () => { + const captured = []; + await spawnP( + "npx", + ["hyperframes", "tts"], + {}, + "win32", + fakeSpawn(captured), + envWithNpxCli, + pathExists, + ); + assert.equal(captured.length, 1); + assert.equal(captured[0].cmd, envWithNpxCli.npm_node_execpath); + assert.deepEqual(captured[0].args, [npxCliPath, "hyperframes", "tts"]); + assert.equal(captured[0].opts.shell, undefined); +}); + +test("spawnP does not enable shell for npx on darwin/linux", async () => { + const captured = []; + await spawnP("npx", ["hyperframes", "tts"], {}, "darwin", fakeSpawn(captured)); + assert.equal(captured[0].cmd, "npx"); + assert.deepEqual(captured[0].args, ["hyperframes", "tts"]); + assert.equal(captured[0].opts.shell, undefined); +}); + +test("spawnP does not enable shell for non-npx commands even on win32", async () => { + const captured = []; + await spawnP("python3", ["-c", "pass"], {}, "win32", fakeSpawn(captured)); + assert.equal(captured[0].cmd, "python3"); + assert.deepEqual(captured[0].args, ["-c", "pass"]); + assert.equal(captured[0].opts.shell, undefined); +});